From 5d6fcdd78dd0e8066eb1c0c1e494ec9e2cea8b67 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:32:04 +0100 Subject: [PATCH 1/4] 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). --- app/pages/member/account.vue | 62 +++++++++++++++++++ server/api/auth/member.get.js | 1 + server/api/helcim/subscription.get.js | 56 +++++++++++++++++ server/api/helcim/subscription.post.js | 7 +++ .../api/members/cancel-subscription.post.js | 1 + .../api/members/update-contribution.post.js | 12 +++- server/utils/helcim.js | 3 + tests/server/api/update-contribution.test.js | 7 ++- 8 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 server/api/helcim/subscription.get.js 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) From 9a407c2a38c6d0d7ea95c3d5ded0b663491d1880 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:32:08 +0100 Subject: [PATCH 2/4] fix(billing): exclude verify + zero-amount rows from payment history Helcim's card-transactions list includes auth-only "verify" rows and $0 entries that have no value on the member-facing history. --- server/utils/helcim.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/utils/helcim.js b/server/utils/helcim.js index 11b4775..c62c628 100644 --- a/server/utils/helcim.js +++ b/server/utils/helcim.js @@ -191,7 +191,13 @@ export async function listHelcimCustomerTransactions(customerCode) { ? response : (response?.transactions || response?.data || []) - const sorted = [...rows].sort((a, b) => { + const filtered = rows.filter((t) => { + const type = String(t?.type || '').toLowerCase() + const amount = typeof t?.amount === 'number' ? t.amount : Number(t?.amount) || 0 + return type !== 'verify' && amount > 0 + }) + + const sorted = [...filtered].sort((a, b) => { const da = Date.parse(a?.dateCreated || '') || 0 const db = Date.parse(b?.dateCreated || '') || 0 return db - da From 1b0a6356a7581015b343bdedcc958db968ccd5d5 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:32:13 +0100 Subject: [PATCH 3/4] feat(activity): add member onboarding activity types Reserve member_onboarding_goal_completed and member_onboarding_completed so upcoming onboarding instrumentation can log without schema churn. --- server/models/activityLog.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/models/activityLog.js b/server/models/activityLog.js index bb23b72..47a66c6 100644 --- a/server/models/activityLog.js +++ b/server/models/activityLog.js @@ -21,7 +21,9 @@ const ACTIVITY_TYPES = [ 'connection_requested', 'connection_confirmed', 'tag_suggested', - 'billing_card_updated' + 'billing_card_updated', + 'member_onboarding_goal_completed', + 'member_onboarding_completed' ] const activityLogSchema = new mongoose.Schema({ From 6924758f9949eebef62f38bdcffa1057839c0e0e Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:32:25 +0100 Subject: [PATCH 4/4] docs(launch): check off change-card, magic-link, ticket manual tests Event ticket purchase, magic-link login, and in-app change-card verified 2026-04-19. Pre-registrant invite flow deferred pending no-tiers refactor on parallel worktree. --- docs/LAUNCH_READINESS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index dd4fdd4..9313462 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -70,14 +70,14 @@ Pre-deploy migrations have all been run. What's left: Cannot be verified by Vitest. All require a real browser + real Helcim test card + real email. -- [ ] **Event ticket purchase with payment** (HelcimPay.js iframe; use cloudflared tunnel or ngrok HTTPS). -- [ ] **Pre-registrant invite → accept flow** with paid tier (exercises Helcim customer creation during acceptance). -- [ ] **Magic-link login** including 15-min expiry and jti burn on reuse. +- [x] **Event ticket purchase with payment** (HelcimPay.js iframe; use cloudflared tunnel or ngrok HTTPS). *(Verified 2026-04-19 via tunnel: guest purchase on "Cooperative Game Dev Masterclass" succeeded — registration recorded with `paymentStatus: completed`, `paymentId: 47230660`, `tickets.public.sold` incremented, guest Member created. Member-ticket path not exercised because this event's member price is $0; no paid-member-ticket event currently seeded.)* +- [ ] **Pre-registrant invite → accept flow** with paid tier (exercises Helcim customer creation during acceptance). *(Deferred 2026-04-19 — no-tiers refactor landing on a parallel worktree will replace the accept-invite payment flow; retest once that lands.)* +- [x] **Magic-link login** including 15-min expiry and jti burn on reuse. *(Verified 2026-04-19 against local dev + local Mongo. Target: `alex.rivera@pixelcollective.coop` (active, member role). Happy path: `POST /api/auth/login` → 200, `magicLinkJti` set, `magicLinkJtiUsed:false`; reconstructed token from stored jti + `NUXT_JWT_SECRET` and `POST /api/auth/verify` → 200 with `redirectUrl:/member/dashboard`, `auth-token` cookie set (httpOnly, Max-Age=604800, SameSite=Lax), `magicLinkJtiUsed:true`, `lastLogin` updated. Replay: same token re-POSTed → 401 at `verify.post.js:53` (jti-burn branch), Mongo state unchanged. Expiry: `jwt.sign({...},{expiresIn:'-1s'})` with fresh unburned jti on the member → 401 at `verify.post.js:22` (jwt.verify catch, before jti check), no mutation.)* - [x] **Guest event signup** — four branches: new email + consent, new email without consent, existing guest, existing active member. Confirms cookie only sets for new/guest, and confirmation email appends `/login` link for real members. *(Verified 2026-04-19 via tunnel with throwaway event + timestamped test emails; cleanup done.)* - [x] **Mobile responsive layout** — main chrome sidebar hides ≤768px (not ≤1024px as previously noted); in-page two-column layouts collapse at ≤1024px. Mobile header/drawer works on phone widths. *(Verified 2026-04-19.)* - [x] **`--text-dim` / `--text-faint` WCAG AA contrast check.** *(Verified 2026-04-19; only live failure was `.circle-desc` on selected/hover tiles — fixed in `e7ad076` by promoting from `--text-faint` to `--text-dim`.)* - [x] **In-app payment history** (`/member/account` → Past payments section). Verified: active monthly subscriber sees past charges with dates/amounts; annual subscriber sees their single upfront charge; member with no payments shows empty state; cancelled member still sees historical charges. **Per-row download/view link NOT implemented** — Helcim's `/card-transactions/` API doesn't expose per-transaction receipt URLs. Accepted as satisfied by the existing "Advanced billing in Helcim →" escape hatch, which lands members in the Helcim portal where receipts are downloadable. *(Verified 2026-04-19.)* -- [ ] **In-app change card** (`/member/account` → Change card). Verify: HelcimPay.js modal opens, new card tokenizes, customer's default payment method updates in Helcim dashboard, active subscription's payment method updates, and `billing_card_updated` activity log entry is written. Force a failure path (e.g. invalid card or Helcim 4xx after default updates) to confirm the rollback in `server/api/helcim/update-card.post.js` actually restores the prior default. +- [x] **In-app change card** (`/member/account` → Change card). Verify: HelcimPay.js modal opens, new card tokenizes, customer's default payment method updates in Helcim dashboard, active subscription's payment method updates, and `billing_card_updated` activity log entry is written. Force a failure path (e.g. invalid card or Helcim 4xx after default updates) to confirm the rollback in `server/api/helcim/update-card.post.js` actually restores the prior default. *(Verified 2026-04-19.)* ---