feat(account): in-app payment history + change card
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.
This commit is contained in:
parent
eaff5c6020
commit
19c77a3ab6
1 changed files with 280 additions and 5 deletions
|
|
@ -66,14 +66,83 @@
|
|||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
</PageSection>
|
||||
|
||||
<!-- PAYMENT HISTORY (only when a Helcim customer exists) -->
|
||||
<PageSection
|
||||
v-if="memberData.helcimCustomerId"
|
||||
divider="top"
|
||||
>
|
||||
<div class="section-label">Payment history</div>
|
||||
|
||||
<div v-if="paymentHistoryLoading" class="history-card">
|
||||
<div class="history-row history-state">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="paymentHistoryError"
|
||||
class="history-card"
|
||||
>
|
||||
<div class="history-row history-state">
|
||||
Payment history temporarily unavailable. Try again in a few minutes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="paymentHistory.length === 0"
|
||||
class="history-card"
|
||||
>
|
||||
<div class="history-row history-state">
|
||||
No payments yet. Your first charge will appear here after your next billing cycle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="history-card">
|
||||
<div
|
||||
v-for="txn in paymentHistory"
|
||||
:key="txn.id"
|
||||
class="history-row"
|
||||
>
|
||||
<span class="history-date">{{ formatTxnDate(txn.date) }}</span>
|
||||
<span class="history-amount">{{ formatTxnAmount(txn.amount, txn.currency) }}</span>
|
||||
<span
|
||||
class="history-status"
|
||||
:class="`status-${txn.status}`"
|
||||
>{{ formatTxnStatus(txn.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<!-- CHANGE CARD (only for active subscriptions) -->
|
||||
<PageSection
|
||||
v-if="canChangeCard"
|
||||
divider="top"
|
||||
>
|
||||
<div class="section-label">Change card</div>
|
||||
<p class="change-card-hint">
|
||||
Replace the card on file. Future charges will use the new card.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
:disabled="isChangingCard"
|
||||
@click="handleChangeCard"
|
||||
>
|
||||
{{ changeCardButtonLabel }}
|
||||
</button>
|
||||
</PageSection>
|
||||
|
||||
<!-- ADVANCED BILLING LINK (escape hatch) -->
|
||||
<PageSection
|
||||
v-if="helcimPortalUrl && memberData.helcimCustomerId"
|
||||
divider="top"
|
||||
>
|
||||
<a
|
||||
:href="helcimPortalUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="billing-link"
|
||||
>
|
||||
Manage billing in Helcim →
|
||||
Advanced billing in Helcim →
|
||||
</a>
|
||||
</PageSection>
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue