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)