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>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
v-if="helcimPortalUrl && memberData.helcimCustomerId"
|
||||||
|
divider="top"
|
||||||
|
>
|
||||||
|
<a
|
||||||
:href="helcimPortalUrl"
|
:href="helcimPortalUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
class="billing-link"
|
class="billing-link"
|
||||||
>
|
>
|
||||||
Manage billing in Helcim →
|
Advanced billing in Helcim →
|
||||||
</a>
|
</a>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|
||||||
|
|
@ -216,6 +285,7 @@ definePageMeta({
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal();
|
const { openLoginModal } = useLoginModal();
|
||||||
|
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
|
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
|
||||||
|
|
||||||
|
|
@ -229,6 +299,26 @@ const showEmailEdit = ref(false);
|
||||||
const newEmail = ref("");
|
const newEmail = ref("");
|
||||||
const isUpdatingEmail = ref(false);
|
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 = [
|
const BASE_TIERS = [
|
||||||
{ amount: 0, label: "I need support right now" },
|
{ amount: 0, label: "I need support right now" },
|
||||||
{ amount: 5, label: "I can contribute" },
|
{ 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 showCancelConfirm = ref(false);
|
||||||
|
|
||||||
const handleCancelMembership = () => {
|
const handleCancelMembership = () => {
|
||||||
|
|
@ -577,14 +804,62 @@ const confirmCancelMembership = async () => {
|
||||||
|
|
||||||
.billing-link {
|
.billing-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 10px;
|
font-size: 11px;
|
||||||
font-size: 12px;
|
letter-spacing: 0.04em;
|
||||||
color: var(--candle);
|
color: var(--text-faint);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.billing-link:hover {
|
.billing-link:hover {
|
||||||
|
color: var(--candle);
|
||||||
text-decoration: underline;
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue