From 101d6a231b5f80d76e7189387402d9eaf6ec4337 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 16:29:23 +0100 Subject: [PATCH] feat(billing): add update-card API route with rollback + status gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/api/helcim/update-card.post.js | 132 +++++++++++++ tests/server/api/helcim-update-card.test.js | 196 ++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 server/api/helcim/update-card.post.js create mode 100644 tests/server/api/helcim-update-card.test.js diff --git a/server/api/helcim/update-card.post.js b/server/api/helcim/update-card.post.js new file mode 100644 index 0000000..b3dd2d9 --- /dev/null +++ b/server/api/helcim/update-card.post.js @@ -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 } +}) diff --git a/tests/server/api/helcim-update-card.test.js b/tests/server/api/helcim-update-card.test.js new file mode 100644 index 0000000..4740999 --- /dev/null +++ b/tests/server/api/helcim-update-card.test.js @@ -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() + }) +})