feat(account): show next payment date with lazy Helcim refresh

Persist nextBillingDate on subscription create/update; unset on
cancel or downgrade to free. Account page displays the cached
date and lazily refreshes from Helcim when the cached value is
within 24h of now (or missing).
This commit is contained in:
Jennie Robinson Faber 2026-04-19 18:32:04 +01:00
parent 4da0265935
commit 5d6fcdd78d
8 changed files with 146 additions and 3 deletions

View file

@ -59,6 +59,10 @@
<span class="membership-k">Contribution</span>
<span class="membership-v">{{ currentContributionLabel }}</span>
</div>
<div v-if="nextPaymentDate" class="membership-row">
<span class="membership-k">Next payment</span>
<span class="membership-v">{{ formatNextPaymentDate(nextPaymentDate) }}</span>
</div>
<div class="membership-row">
<span class="membership-k">Member since</span>
<span class="membership-v">{{
@ -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);

View file

@ -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,

View file

@ -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' }
}
})

View file

@ -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 }
)

View file

@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
paymentMethod: 'none',
subscriptionEndDate: new Date(),
},
$unset: { nextBillingDate: 1 },
},
{ runValidators: false }
);

View file

@ -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 }
);

View file

@ -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' })

View file

@ -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)