merge: catch up with feature/helcim-plan-consolidation base
# Conflicts: # server/api/auth/member.get.js # server/api/members/update-contribution.post.js # tests/server/api/update-contribution.test.js
This commit is contained in:
commit
7704557f16
10 changed files with 160 additions and 9 deletions
|
|
@ -59,6 +59,10 @@
|
||||||
<span class="membership-k">Contribution</span>
|
<span class="membership-k">Contribution</span>
|
||||||
<span class="membership-v">{{ currentContributionLabel }}</span>
|
<span class="membership-v">{{ currentContributionLabel }}</span>
|
||||||
</div>
|
</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">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Member since</span>
|
<span class="membership-k">Member since</span>
|
||||||
<span class="membership-v">{{
|
<span class="membership-v">{{
|
||||||
|
|
@ -333,6 +337,18 @@ const paymentHistoryLoading = ref(false);
|
||||||
const paymentHistoryError = ref(false);
|
const paymentHistoryError = ref(false);
|
||||||
const paymentHistoryLoaded = 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
|
// Change-card state
|
||||||
const isChangingCard = ref(false);
|
const isChangingCard = ref(false);
|
||||||
const changeCardButtonLabel = ref("Change card");
|
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 () => {
|
const handleUpdateTier = async () => {
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -514,6 +568,7 @@ onMounted(() => {
|
||||||
if (memberData.value?.helcimCustomerId) {
|
if (memberData.value?.helcimCustomerId) {
|
||||||
loadPaymentHistory();
|
loadPaymentHistory();
|
||||||
}
|
}
|
||||||
|
refreshNextBillingIfStale();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -525,6 +580,13 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => memberData.value?.status,
|
||||||
|
() => {
|
||||||
|
refreshNextBillingIfStale();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const formatTxnDate = (iso) => {
|
const formatTxnDate = (iso) => {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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).
|
- [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).
|
- [ ] **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.)*
|
||||||
- [ ] **Magic-link login** including 15-min expiry and jti burn on reuse.
|
- [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] **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] **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] **`--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.)*
|
- [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.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||||
contributionAmount: member.contributionAmount,
|
contributionAmount: member.contributionAmount,
|
||||||
billingCadence: member.billingCadence,
|
billingCadence: member.billingCadence,
|
||||||
helcimCustomerId: member.helcimCustomerId,
|
helcimCustomerId: member.helcimCustomerId,
|
||||||
|
nextBillingDate: member.nextBillingDate,
|
||||||
membershipLevel: `${member.circle}-${member.contributionAmount}`,
|
membershipLevel: `${member.circle}-${member.contributionAmount}`,
|
||||||
// Profile fields
|
// Profile fields
|
||||||
pronouns: member.pronouns,
|
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' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -165,6 +165,10 @@ export default defineEventHandler(async (event) => {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' })
|
throw createError({ statusCode: 500, statusMessage: 'Subscription creation failed' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextBillingDate = subscription.nextBillingDate
|
||||||
|
? new Date(subscription.nextBillingDate)
|
||||||
|
: null
|
||||||
|
|
||||||
// Update member in database
|
// Update member in database
|
||||||
const member = await Member.findOneAndUpdate(
|
const member = await Member.findOneAndUpdate(
|
||||||
{ helcimCustomerId: body.customerId },
|
{ helcimCustomerId: body.customerId },
|
||||||
|
|
@ -176,6 +180,9 @@ export default defineEventHandler(async (event) => {
|
||||||
billingCadence: cadence,
|
billingCadence: cadence,
|
||||||
subscriptionStartDate: new Date(),
|
subscriptionStartDate: new Date(),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
|
||||||
|
? { nextBillingDate }
|
||||||
|
: {}),
|
||||||
} },
|
} },
|
||||||
{ new: true, runValidators: false }
|
{ new: true, runValidators: false }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||||
paymentMethod: 'none',
|
paymentMethod: 'none',
|
||||||
subscriptionEndDate: new Date(),
|
subscriptionEndDate: new Date(),
|
||||||
},
|
},
|
||||||
|
$unset: { nextBillingDate: 1 },
|
||||||
},
|
},
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,10 @@ export default defineEventHandler(async (event) => {
|
||||||
throw new Error("No subscription returned in response");
|
throw new Error("No subscription returned in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextBillingDate = subscription.nextBillingDate
|
||||||
|
? new Date(subscription.nextBillingDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Update member record
|
// Update member record
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
member._id,
|
||||||
|
|
@ -107,6 +111,9 @@ export default defineEventHandler(async (event) => {
|
||||||
paymentMethod: "card",
|
paymentMethod: "card",
|
||||||
status: "active",
|
status: "active",
|
||||||
billingCadence: cadence,
|
billingCadence: cadence,
|
||||||
|
...(nextBillingDate && !Number.isNaN(nextBillingDate.getTime())
|
||||||
|
? { nextBillingDate }
|
||||||
|
: {}),
|
||||||
} },
|
} },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
@ -148,7 +155,10 @@ export default defineEventHandler(async (event) => {
|
||||||
// Update member to free tier
|
// Update member to free tier
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
member._id,
|
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 }
|
{ runValidators: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ const ACTIVITY_TYPES = [
|
||||||
'connection_requested',
|
'connection_requested',
|
||||||
'connection_confirmed',
|
'connection_confirmed',
|
||||||
'tag_suggested',
|
'tag_suggested',
|
||||||
'billing_card_updated'
|
'billing_card_updated',
|
||||||
|
'member_onboarding_goal_completed',
|
||||||
|
'member_onboarding_completed'
|
||||||
]
|
]
|
||||||
|
|
||||||
const activityLogSchema = new mongoose.Schema({
|
const activityLogSchema = new mongoose.Schema({
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,9 @@ export const createHelcimSubscription = (subscription, idempotencyKey) =>
|
||||||
errorMessage: 'Subscription creation failed'
|
errorMessage: 'Subscription creation failed'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getHelcimSubscription = (id) =>
|
||||||
|
helcimFetch(`/subscriptions/${id}`, { errorMessage: 'Subscription lookup failed' })
|
||||||
|
|
||||||
export const cancelHelcimSubscription = (id) =>
|
export const cancelHelcimSubscription = (id) =>
|
||||||
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
|
helcimFetch(`/subscriptions/${id}`, { method: 'DELETE', errorMessage: 'Subscription cancellation failed' })
|
||||||
|
|
||||||
|
|
@ -188,7 +191,13 @@ export async function listHelcimCustomerTransactions(customerCode) {
|
||||||
? response
|
? response
|
||||||
: (response?.transactions || response?.data || [])
|
: (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 da = Date.parse(a?.dateCreated || '') || 0
|
||||||
const db = Date.parse(b?.dateCreated || '') || 0
|
const db = Date.parse(b?.dateCreated || '') || 0
|
||||||
return db - da
|
return db - da
|
||||||
|
|
|
||||||
|
|
@ -327,12 +327,15 @@ describe('update-contribution endpoint — Case 2 (paid→free)', () => {
|
||||||
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
|
expect(cancelHelcimSubscription).toHaveBeenCalledWith('sub-1')
|
||||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
'member-c2',
|
'member-c2',
|
||||||
{ $set: expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({
|
||||||
contributionAmount: 0,
|
contributionAmount: 0,
|
||||||
helcimSubscriptionId: null,
|
helcimSubscriptionId: null,
|
||||||
paymentMethod: 'none',
|
paymentMethod: 'none',
|
||||||
billingCadence: 'monthly',
|
billingCadence: 'monthly',
|
||||||
}) },
|
}),
|
||||||
|
$unset: expect.objectContaining({ nextBillingDate: 1 }),
|
||||||
|
}),
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
)
|
)
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue