From 19c77a3ab690d09c02e443d67ed61c96289b797c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 16:36:19 +0100 Subject: [PATCH] feat(account): in-app payment history + change card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Payment history section (live-read from Helcim, with loading/empty/error states) and Change card flow (HelcimPay.js zero-dollar auth -> POST /api/helcim/update-card) to /member/account. Relabel Helcim portal link to "Advanced billing in Helcim →" and demote it to a secondary link at the bottom of the billing group. --- app/pages/member/account.vue | 285 ++++++++++++++++++++++++++++++++++- 1 file changed, 280 insertions(+), 5 deletions(-) diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index d16ddab..8bdb730 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -66,14 +66,83 @@ }} + + + + + + +
+
Loading…
+
+ +
+
+ Payment history temporarily unavailable. Try again in a few minutes. +
+
+ +
+
+ No payments yet. Your first charge will appear here after your next billing cycle. +
+
+ +
+
+ {{ formatTxnDate(txn.date) }} + {{ formatTxnAmount(txn.amount, txn.currency) }} + {{ formatTxnStatus(txn.status) }} +
+
+
+ + + + +

+ Replace the card on file. Future charges will use the new card. +

+ +
+ + + - Manage billing in Helcim → + Advanced billing in Helcim → @@ -216,6 +285,7 @@ definePageMeta({ const { memberData, checkMemberStatus } = useAuth(); const { openLoginModal } = useLoginModal(); +const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay(); const toast = useToast(); const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || ''; @@ -229,6 +299,26 @@ const showEmailEdit = ref(false); const newEmail = ref(""); const isUpdatingEmail = ref(false); +// Payment history state +const paymentHistory = ref([]); +const paymentHistoryLoading = ref(false); +const paymentHistoryError = ref(false); +const paymentHistoryLoaded = ref(false); + +// Change-card state +const isChangingCard = ref(false); +const changeCardButtonLabel = ref("Change card"); + +const canChangeCard = computed(() => { + const m = memberData.value; + if (!m) return false; + if (!m.helcimCustomerId) return false; + if (!["active", "pending_payment"].includes(m.status)) return false; + // $0 tier has no subscription to attach a card to + if (String(m.contributionTier || "0") === "0") return false; + return true; +}); + const BASE_TIERS = [ { amount: 0, label: "I need support right now" }, { amount: 5, label: "I can contribute" }, @@ -385,6 +475,143 @@ const handleUpdateEmail = async () => { } }; +// Payment history +const loadPaymentHistory = async () => { + if (paymentHistoryLoaded.value) return; + if (!memberData.value?.helcimCustomerId) return; + paymentHistoryLoading.value = true; + paymentHistoryError.value = false; + try { + const response = await $fetch("/api/helcim/payment-history"); + if (response?.error === "unavailable") { + paymentHistoryError.value = true; + paymentHistory.value = []; + } else { + paymentHistory.value = Array.isArray(response?.transactions) + ? response.transactions + : []; + } + } catch (err) { + paymentHistoryError.value = true; + paymentHistory.value = []; + } finally { + paymentHistoryLoading.value = false; + paymentHistoryLoaded.value = true; + } +}; + +onMounted(() => { + if (memberData.value?.helcimCustomerId) { + loadPaymentHistory(); + } +}); + +watch( + () => memberData.value?.helcimCustomerId, + (id) => { + if (id && !paymentHistoryLoaded.value) { + loadPaymentHistory(); + } + }, +); + +const formatTxnDate = (iso) => { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); +}; + +const formatTxnAmount = (amount, currency) => { + const num = Number(amount) || 0; + const cur = currency ? ` ${currency}` : ""; + return `$${num.toFixed(2)}${cur}`; +}; + +const STATUS_BADGE = { + paid: "Paid", + refunded: "Refunded", + failed: "Failed", + other: "—", +}; + +const formatTxnStatus = (s) => STATUS_BADGE[s] || "—"; + +// Change card +const handleChangeCard = async () => { + if (isChangingCard.value) return; + if (!canChangeCard.value) return; + + isChangingCard.value = true; + changeCardButtonLabel.value = "Opening…"; + + try { + // Fetch current customer id + code + const customerResponse = await $fetch("/api/helcim/customer-code"); + const customerId = customerResponse?.customerId; + const customerCode = customerResponse?.customerCode; + if (!customerId || !customerCode) { + throw new Error("Could not locate customer record"); + } + + await initializeHelcimPay(customerId, customerCode, 0); + + let paymentResult; + try { + paymentResult = await verifyPayment(); + } catch (cancelOrFailure) { + // User cancelled or iframe failed — no server call + return; + } + + if (!paymentResult?.success || !paymentResult?.cardToken) { + return; + } + + changeCardButtonLabel.value = "Updating…"; + + try { + await $fetch("/api/helcim/update-card", { + method: "POST", + body: { cardToken: paymentResult.cardToken }, + }); + toast.add({ + title: "Card updated", + description: "Future charges will use your new card.", + color: "success", + }); + } catch (err) { + console.error("[change-card] update failed", err); + toast.add({ + title: "Could not update card", + description: "Please try again.", + color: "error", + actions: [ + { + label: "Retry", + onClick: () => handleChangeCard(), + }, + ], + }); + } + } catch (err) { + console.error("[change-card] flow failed", err); + toast.add({ + title: "Could not update card", + description: "Please try again.", + color: "error", + }); + } finally { + cleanupHelcimPay(); + isChangingCard.value = false; + changeCardButtonLabel.value = "Change card"; + } +}; + const showCancelConfirm = ref(false); const handleCancelMembership = () => { @@ -577,14 +804,62 @@ const confirmCancelMembership = async () => { .billing-link { display: inline-block; - margin-top: 10px; - font-size: 12px; - color: var(--candle); + font-size: 11px; + letter-spacing: 0.04em; + color: var(--text-faint); text-decoration: none; } .billing-link:hover { + color: var(--candle); text-decoration: underline; } +/* ---- PAYMENT HISTORY ---- */ +.history-card { + border: 1px dashed var(--border); + padding: 0; + margin-bottom: 12px; +} +.history-row { + display: grid; + grid-template-columns: 120px 1fr auto; + gap: 0 12px; + align-items: center; + padding: 10px 20px; + font-size: 12px; + border-bottom: 1px dashed var(--border); +} +.history-row:last-child { + border-bottom: none; +} +.history-date { + color: var(--text-faint); +} +.history-amount { + color: var(--text); +} +.history-status { + color: var(--text-faint); + font-size: 11px; + letter-spacing: 0.04em; +} +.history-status.status-failed { + color: var(--ember); +} +.history-state { + color: var(--text-faint); + font-style: italic; + display: block; + grid-template-columns: 1fr; +} + +/* ---- CHANGE CARD ---- */ +.change-card-hint { + font-size: 11px; + color: var(--text-faint); + margin-bottom: 12px; + line-height: 1.6; +} +