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:
parent
4da0265935
commit
5d6fcdd78d
8 changed files with 146 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
server/api/helcim/subscription.get.js
Normal file
56
server/api/helcim/subscription.get.js
Normal 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' }
|
||||
}
|
||||
})
|
||||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
|||
paymentMethod: 'none',
|
||||
subscriptionEndDate: new Date(),
|
||||
},
|
||||
$unset: { nextBillingDate: 1 },
|
||||
},
|
||||
{ runValidators: false }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue