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.
132 lines
5.1 KiB
JavaScript
132 lines
5.1 KiB
JavaScript
// 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 }
|
|
})
|