diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue
index 8bdb730..c8de906 100644
--- a/app/pages/member/account.vue
+++ b/app/pages/member/account.vue
@@ -59,6 +59,10 @@
Contribution
{{ currentContributionLabel }}
+
+ Next payment
+ {{ formatNextPaymentDate(nextPaymentDate) }}
+
Member since
{{
@@ -305,6 +309,18 @@ const paymentHistoryLoading = ref(false);
const paymentHistoryError = ref(false);
const paymentHistoryLoaded = ref(false);
+// Next payment (refreshed lazily from Helcim when cached date is stale)
+const refreshedNextBillingDate = ref(null);
+const nextBillingRefreshed = ref(false);
+
+const nextPaymentDate = computed(() => {
+ const m = memberData.value;
+ if (!m) return null;
+ if (m.status !== 'active') return null;
+ if (String(m.contributionTier || '0') === '0') return null;
+ return refreshedNextBillingDate.value || m.nextBillingDate || null;
+});
+
// Change-card state
const isChangingCard = ref(false);
const changeCardButtonLabel = ref("Change card");
@@ -393,6 +409,44 @@ const formatMemberSince = (dateStr) => {
});
};
+const formatNextPaymentDate = (dateStr) => {
+ if (!dateStr) return "";
+ const d = new Date(dateStr);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+const STALE_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+const isNextBillingStale = (dateStr) => {
+ if (!dateStr) return true;
+ const d = new Date(dateStr);
+ if (Number.isNaN(d.getTime())) return true;
+ return d.getTime() - Date.now() < STALE_WINDOW_MS;
+};
+
+const refreshNextBillingIfStale = async () => {
+ if (nextBillingRefreshed.value) return;
+ const m = memberData.value;
+ if (!m) return;
+ if (m.status !== 'active') return;
+ if (String(m.contributionTier || '0') === '0') return;
+ if (!isNextBillingStale(m.nextBillingDate)) return;
+
+ nextBillingRefreshed.value = true;
+ try {
+ const response = await $fetch("/api/helcim/subscription");
+ const fresh = response?.subscription?.nextBillingDate;
+ if (fresh) refreshedNextBillingDate.value = fresh;
+ } catch (err) {
+ // Silent — fall back to cached value (if any)
+ }
+};
+
const handleUpdateTier = async () => {
isUpdating.value = true;
try {
@@ -504,6 +558,7 @@ onMounted(() => {
if (memberData.value?.helcimCustomerId) {
loadPaymentHistory();
}
+ refreshNextBillingIfStale();
});
watch(
@@ -515,6 +570,13 @@ watch(
},
);
+watch(
+ () => memberData.value?.status,
+ () => {
+ refreshNextBillingIfStale();
+ },
+);
+
const formatTxnDate = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js
index 5dbcfd8..bbcae66 100644
--- a/server/api/auth/member.get.js
+++ b/server/api/auth/member.get.js
@@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
contributionTier: member.contributionTier,
billingCadence: member.billingCadence,
helcimCustomerId: member.helcimCustomerId,
+ nextBillingDate: member.nextBillingDate,
membershipLevel: `${member.circle}-${member.contributionTier}`,
// Profile fields
pronouns: member.pronouns,
diff --git a/server/api/helcim/subscription.get.js b/server/api/helcim/subscription.get.js
new file mode 100644
index 0000000..99d2bc5
--- /dev/null
+++ b/server/api/helcim/subscription.get.js
@@ -0,0 +1,56 @@
+// Refresh the authenticated member's cached nextBillingDate from Helcim.
+// The account page calls this only when the stored date is stale (missing,
+// past, or within ~24h). On success, writes the fresh date back to the member
+// record so subsequent loads can render instantly from /api/auth/member.
+//
+// On Helcim errors, returns { subscription: null, error: 'unavailable' } (HTTP 200)
+// so the client can fall back to the cached value (if any) without crashing.
+import { requireAuth } from '../../utils/auth.js'
+import { getHelcimSubscription } from '../../utils/helcim.js'
+import Member from '../../models/member.js'
+import { connectDB } from '../../utils/mongoose.js'
+
+export default defineEventHandler(async (event) => {
+ const member = await requireAuth(event)
+
+ if (!member.helcimSubscriptionId) {
+ return { subscription: null }
+ }
+
+ try {
+ const response = await getHelcimSubscription(member.helcimSubscriptionId)
+ const subscription = Array.isArray(response)
+ ? response[0]
+ : (response?.data?.[0] || response)
+
+ const nextBillingDate = subscription?.nextBillingDate || null
+
+ if (nextBillingDate) {
+ const parsed = new Date(nextBillingDate)
+ if (!Number.isNaN(parsed.getTime())) {
+ await connectDB()
+ await Member.findByIdAndUpdate(
+ member._id,
+ { $set: { nextBillingDate: parsed } },
+ { runValidators: false }
+ )
+ }
+ }
+
+ return {
+ subscription: subscription
+ ? {
+ id: String(subscription.id ?? ''),
+ status: subscription.status || '',
+ nextBillingDate: nextBillingDate || '',
+ }
+ : null,
+ }
+ } catch (error) {
+ console.error('[subscription.get] Helcim lookup failed', {
+ helcimSubscriptionId: member.helcimSubscriptionId,
+ error: error?.message || error,
+ })
+ return { subscription: null, error: 'unavailable' }
+ }
+})
diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js
index 7d9c015..8a7d7de 100644
--- a/server/api/helcim/subscription.post.js
+++ b/server/api/helcim/subscription.post.js
@@ -163,6 +163,10 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' })
}
+ const nextBillingDate = subscription.nextBillingDate
+ ? new Date(subscription.nextBillingDate)
+ : null
+
// Update member in database
const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId },
@@ -174,6 +178,9 @@ export default defineEventHandler(async (event) => {
billingCadence: cadence,
subscriptionStartDate: new Date(),
status: 'active',
+ ...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
+ ? { nextBillingDate }
+ : {}),
} },
{ new: true, runValidators: false }
)
diff --git a/server/api/members/cancel-subscription.post.js b/server/api/members/cancel-subscription.post.js
index b16d54a..74af600 100644
--- a/server/api/members/cancel-subscription.post.js
+++ b/server/api/members/cancel-subscription.post.js
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
paymentMethod: 'none',
subscriptionEndDate: new Date(),
},
+ $unset: { nextBillingDate: 1 },
},
{ runValidators: false }
);
diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js
index fd236f8..c067964 100644
--- a/server/api/members/update-contribution.post.js
+++ b/server/api/members/update-contribution.post.js
@@ -102,6 +102,10 @@ export default defineEventHandler(async (event) => {
throw new Error("No subscription returned in response");
}
+ const nextBillingDate = subscription.nextBillingDate
+ ? new Date(subscription.nextBillingDate)
+ : null;
+
// Update member record
await Member.findByIdAndUpdate(
member._id,
@@ -111,6 +115,9 @@ export default defineEventHandler(async (event) => {
paymentMethod: "card",
status: "active",
billingCadence: cadence,
+ ...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
+ ? { nextBillingDate }
+ : {}),
} },
{ runValidators: false }
);
@@ -152,7 +159,10 @@ export default defineEventHandler(async (event) => {
// Update member to free tier
await Member.findByIdAndUpdate(
member._id,
- { $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } },
+ {
+ $set: { contributionTier: newTier, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" },
+ $unset: { nextBillingDate: 1 },
+ },
{ runValidators: false }
);
diff --git a/server/utils/helcim.js b/server/utils/helcim.js
index 11216a4..11b4775 100644
--- a/server/utils/helcim.js
+++ b/server/utils/helcim.js
@@ -134,6 +134,9 @@ export const createHelcimSubscription = (subscription, idempotencyKey) =>
errorMessage: 'Subscription creation failed'
})
+export const getHelcimSubscription = (id) =>
+ helcimFetch(`/subscriptions/${id}`, { errorMessage: 'Subscription lookup failed' })
+
export const cancelHelcimSubscription = (id) =>
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js
index 38e18c1..d79684a 100644
--- a/tests/server/api/update-contribution.test.js
+++ b/tests/server/api/update-contribution.test.js
@@ -345,12 +345,15 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => {
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
'member-c2',
- { $set: expect.objectContaining({
+ expect.objectContaining({
+ $set: expect.objectContaining({
contributionTier: '0',
helcimSubscriptionId: null,
paymentMethod: 'none',
billingCadence: 'monthly',
- }) },
+ }),
+ $unset: expect.objectContaining({ nextBillingDate: 1 }),
+ }),
{ runValidators: false }
)
expect(result.success).toBe(true)