diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index 030c486..cf0c3ed 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 {{ @@ -333,6 +337,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 (!Number(m.contributionAmount)) return null; + return refreshedNextBillingDate.value || m.nextBillingDate || null; +}); + // Change-card state const isChangingCard = ref(false); const changeCardButtonLabel = ref("Change card"); @@ -403,6 +419,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 (!Number(m.contributionAmount)) 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 { @@ -514,6 +568,7 @@ onMounted(() => { if (memberData.value?.helcimCustomerId) { loadPaymentHistory(); } + refreshNextBillingIfStale(); }); watch( @@ -525,6 +580,13 @@ watch( }, ); +watch( + () => memberData.value?.status, + () => { + refreshNextBillingIfStale(); + }, +); + const formatTxnDate = (iso) => { if (!iso) return "—"; const d = new Date(iso); 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.)* --- diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index 8df1173..02316c0 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => { contributionAmount: member.contributionAmount, billingCadence: member.billingCadence, helcimCustomerId: member.helcimCustomerId, + nextBillingDate: member.nextBillingDate, membershipLevel: `${member.circle}-${member.contributionAmount}`, // 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 c939391..a32b751 100644 --- a/server/api/helcim/subscription.post.js +++ b/server/api/helcim/subscription.post.js @@ -165,6 +165,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 }, @@ -176,6 +180,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 a0fada2..4e311a9 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 26f257f..e99c217 100644 --- a/server/api/members/update-contribution.post.js +++ b/server/api/members/update-contribution.post.js @@ -98,6 +98,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, @@ -107,6 +111,9 @@ export default defineEventHandler(async (event) => { paymentMethod: "card", status: "active", billingCadence: cadence, + ...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime()) + ? { nextBillingDate } + : {}), } }, { runValidators: false } ); @@ -148,7 +155,10 @@ export default defineEventHandler(async (event) => { // Update member to free tier await Member.findByIdAndUpdate( member._id, - { $set: { contributionAmount: newAmount, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" } }, + { + $set: { contributionAmount: newAmount, helcimSubscriptionId: null, paymentMethod: "none", billingCadence: "monthly" }, + $unset: { nextBillingDate: 1 }, + }, { runValidators: false } ); 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({ diff --git a/server/utils/helcim.js b/server/utils/helcim.js index 11216a4..c62c628 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' }) @@ -188,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 diff --git a/tests/server/api/update-contribution.test.js b/tests/server/api/update-contribution.test.js index 797c29b..3b60bed 100644 --- a/tests/server/api/update-contribution.test.js +++ b/tests/server/api/update-contribution.test.js @@ -327,12 +327,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({ contributionAmount: 0, helcimSubscriptionId: null, paymentMethod: 'none', billingCadence: 'monthly', - }) }, + }), + $unset: expect.objectContaining({ nextBillingDate: 1 }), + }), { runValidators: false } ) expect(result.success).toBe(true)