Commit graph

182 commits

Author SHA1 Message Date
4e1888ae8e fix(events): read allowIndividualEventTickets from series.tickets
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 10m59s
Test / playwright (push) Has been cancelled
The series-pass gate in register.post.js was checking
`series.allowIndividualEventTickets` at the top level, but the field
lives under `series.tickets.allowIndividualEventTickets` per the
Series schema. Top-level access was always undefined, so `!undefined`
always fired the pass check — blocking drop-in registration even when
an admin enabled `(requiresSeriesTicket=true, allowIndividualEventTickets=true)`.

The bug failed closed (overprotective), so no bypass was possible.
The existing test mirrored the bug by mocking the field at the top
level; updated the three mocks to nest it under `tickets` so the test
shape matches the real schema.
2026-04-20 19:25:24 +01:00
f34b062f2a fix(events): enforce series-pass, hidden, and deadline gates
Pre-launch P0 fixes surfaced by docs/specs/events-functional-test-matrix.md
(Findings 1, 2, 3).

1. Series-pass bypass (Finding 1 / matrix S1 P3): register.post.js now
   loads the linked Series when tickets.requiresSeriesTicket is set and
   rejects drop-in registration unless series.allowIndividualEventTickets
   is true or the user has a valid pass. Data-integrity 500 if the
   referenced series is missing.

2. Hidden-event leak (Finding 2 / matrix E11): extract loadPublicEvent
   into server/utils/loadEvent.js. All five public event endpoints
   ([id].get, register, tickets/available, tickets/reserve,
   tickets/purchase) now go through the helper, which 404s when
   isVisible === false and the requester is not an admin. Admin detection
   uses a new non-throwing getOptionalMember() in server/utils/auth.js
   (extracted from the pattern already inlined in api/auth/status.get.js).

3. Deadline enforcement + legacy pricing retirement (Finding 3 / matrix
   E8): register.post.js and tickets/reserve.post.js delegate gating to
   validateTicketPurchase (which already covers deadline, cancelled,
   started, members-only, sold-out, and already-registered);
   tickets/available.get.js gets an explicit registrationDeadline check.
   Legacy pricing.paymentRequired 402 branch removed from register.post.js.
2026-04-20 19:03:34 +01:00
0ce61756b7 feat(emails): warmer copy across invite, welcome, and event emails
Friendlier tone + ghost emoji on invite/welcome subjects; invite
templates now offer a reply-to-this-email fallback; tighten OIDC
wiki sign-in and event registration confirmation copy.
2026-04-20 13:48:38 +01:00
9fecb7d374 feat(members): add taxReceiptPreferences schema field (Phase 1 forward-compat)
Nested object with filesCanadianTaxes, middleInitial, confirmedAddress
(street/city/province/country/postalCode), setupCompletedAt. All
default to null/false so existing members read as 'not set up'.

Schema-only: no Zod, no API route, no UI. Phase 2 will build the
account-page preferences flow and bind to these fields without
needing a migration.
2026-04-20 13:22:19 +01:00
fc09760a41 feat(payments): log Helcim charge on free-to-paid upgrade
In the Case 1 (free→paid) branch of update-contribution, after the
subscription is created and the member row is updated, fetch the
newest paid Helcim transaction and upsert a Payment with
paymentType=cadence and sendConfirmation=true.

Paid→paid (Case 3) is intentionally NOT wired — no new transaction
occurs at amount change; the next recurring charge is captured by the
reconciliation script.
2026-04-20 13:19:21 +01:00
49cfb47fff feat(payments): log initial Helcim charge to Payment on subscription creation
After a paid subscription is created and the Member row is flipped to
active, fetches the newest paid transaction from Helcim and upserts a
Payment row. Passes paymentType from the chosen cadence and
sendConfirmation: true.

Wrapped in try/catch: a logging failure here never breaks subscription
creation — the reconcile-helcim-payments script will pick up any
misses on the next run.
2026-04-20 13:16:53 +01:00
be7145f96c feat(payments): add upsertPaymentFromHelcim helper with idempotent insert
Takes a Member doc + a normalized Helcim transaction and inserts a
Payment row if helcimTransactionId is unseen. Maps helcim status
paid→success, refunded→refunded, failed→failed; skips 'other'.

opts.paymentType overrides the cadence fallback for mid-flight cadence
changes. opts.sendConfirmation triggers a Resend payment-confirmation
email ONLY on new inserts — swallows send failures so email trouble
cannot break the upstream payment flow.

The Resend template lives in server/emails/paymentConfirmation.js. It
is CRA-safe (charity name + 'not an official donation receipt / tax
receipts available later in 2026' disclaimer) so it can be used in
either Task 8 branch without copy changes.
2026-04-20 13:15:38 +01:00
bf5a333117 feat(payments): add Payment model for Phase 1 receipt data capture
Introduces the payments collection with fields Phase 2 will need to
generate official donation receipts: amount, paymentDate, paymentType
(monthly|annual), helcimTransactionId (unique), and receiptIssued /
receiptId placeholders Phase 2 will populate. Schema-only; no routes
or UI in this commit.
2026-04-20 13:12:17 +01:00
335a4db7cc fix(account): show payment history + next-charge for paid-then-$0 members
Three related changes on /member/account:

1. Payment History section now renders when contributionAmount > 0 OR
   past payments exist. Previously a paid member who switched to $0 lost
   visibility of their own past charges.

2. New "Next charge: $X on DATE" row renders above the transaction list
   when nextPaymentDate is available, using --candle dashed border.

3. server/api/helcim/subscription.get.js now reads dateBilling from
   Helcim's GET response and handles data as either object or array.
   Helcim's real shape is {data: {id, dateBilling, ...}} — the old code
   expected {data: [{nextBillingDate}]} and returned empty strings, so
   the Membership-card "Next payment" row never rendered for members
   whose cached date was missing. subscription.post.js and
   update-contribution.post.js have the same wrong field name in their
   CREATE flows; left for a follow-up — the GET refresh masks it.

Manual edit-flow and admin-flow tests also recorded in
docs/LAUNCH_READINESS.md.
2026-04-20 12:36:18 +01:00
a80728f0a8 feat(signup): unify cadence UX across accept-invite, join, and account
Extract shared SignupFlowOverlay component. Static "Monthly Contribution"
label on all three contribution inputs (was misleadingly dynamic).
"Per Year"/"Per Month" toggle copy; Per Year default on accept-invite,
Per Month default on join. Live billing-summary card on both signup
flows. Welcome-heading on dashboard via ?welcome=1 for new signups.
$0-member polish on account page (hide payment-history + Solidarity
Fund prompts). State-aware contribution-change hint. Invite accept now
creates Helcim customer and sets auth cookie server-side for both free
and paid branches. Pre-registrant invite + /join signup flows manually
verified against Cleo Nguyen preReg and $0-$50 variants.
2026-04-20 12:34:59 +01:00
7704557f16 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
2026-04-19 21:33:40 +01:00
50a1ffe735 chore(contributions): remove unused /api/contributions/options endpoint 2026-04-19 18:56:13 +01:00
57f5152be4 feat(server): rename contributionTier → contributionAmount in routes + utils 2026-04-19 18:44:29 +01:00
7a2acd4628 feat(members): use contributionAmount in update-contribution route, inline ×12 2026-04-19 18:38:14 +01:00
613d077eaa feat(helcim): use contributionAmount, inline ×12 annual math 2026-04-19 18:35:25 +01:00
1b0a6356a7 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.
2026-04-19 18:32:13 +01:00
9a407c2a38 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.
2026-04-19 18:32:08 +01:00
5d6fcdd78d 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).
2026-04-19 18:32:04 +01:00
74ea932cd2 feat(member): rename contributionTier → contributionAmount (Number) 2026-04-19 18:27:35 +01:00
e4dade18b9 feat(validation): rename contributionTier → contributionAmount in Zod schemas 2026-04-19 18:16:47 +01:00
55af652263 feat(contributions): rewrite server config as preset-based helpers 2026-04-19 18:12:44 +01:00
eaff5c6020 feat(activity): add billing_card_updated activity type
Required by POST /api/helcim/update-card to persist audit log entries
when a member updates their card.
2026-04-19 16:30:37 +01:00
101d6a231b feat(billing): add update-card API route with rollback + status gate
POST /api/helcim/update-card updates the customer's default card, then
best-effort patches the active subscription payment method. Status-gated
to {active, pending_payment}; verifies the submitted cardToken is
attached to the member's helcimCustomerId via listHelcimCustomerCards.
On subscription PATCH 5xx we revert the customer default to the prior
card token; 4xx (schema rejection — cardToken is not a documented
subscription PATCH field) is tolerated since the customer default is
the authoritative billing driver.
2026-04-19 16:29:23 +01:00
4f9c11a755 feat(billing): add payment history API route
Add GET /api/helcim/payment-history returning the authenticated
member's normalized Helcim card transactions (newest first, capped
at 50). Resolves helcimCustomerId -> customerCode via getHelcimCustomer
before calling listHelcimCustomerTransactions. Returns
{ transactions: [] } when the member has no helcimCustomerId, and
{ transactions: [], error: 'unavailable' } (HTTP 200) on Helcim
failures so the UI can render fallback copy.

Covered by unit tests at tests/server/api/helcim-payment-history.test.js
(auth, missing customer id, happy path, both Helcim failure paths,
missing customerCode).
2026-04-19 16:26:19 +01:00
6888663148 feat(helcim): add transaction list + card update helpers
- listHelcimCustomerTransactions(customerCode): GET /card-transactions/
  with customerCode filter, sorts newest-first, caps at 50, normalizes
  Helcim status (APPROVED/DECLINED) + type (refund) into
  paid/refunded/failed/other.
- updateHelcimCustomerDefaultPaymentMethod(customerId, cardToken):
  resolves cardToken -> cardId via /customers/{id}/cards, then PATCHes
  /customers/{id}/cards/{cardId}/default.
- updateHelcimSubscriptionPaymentMethod(subscriptionId, cardToken):
  wraps updateHelcimSubscription with a cardToken payload.
- helcimUpdateCardSchema: Zod schema { cardToken: string } for the
  upcoming /api/helcim/update-card route.
- Unit tests for all three helpers (success + error paths).
2026-04-19 16:24:16 +01:00
0ca38e5588 fix(auth): expose helcimCustomerId on /api/auth/member
The member account page gates the Helcim customer portal link on
`memberData.helcimCustomerId`, but this endpoint (the source for
`useAuth().memberData`) omitted the field, so the link hid for every
member regardless of Helcim enrollment. Add the field to the response.
2026-04-19 13:04:46 +01:00
8ceaebb268 fix(helcim): tolerate empty response body on DELETE (204)
helcimFetch called response.json() unconditionally, which threw
"Unexpected end of JSON input" on Helcim's 204 No Content responses
(e.g. DELETE /subscriptions/:id). The error was silently swallowed by
the best-effort cancel path in cancel-subscription, masking cases where
the Helcim-side cancel actually succeeded.
2026-04-18 22:06:33 +01:00
fb337a4277 feat(account): display cadence and annual pricing in tier selector 2026-04-18 18:08:10 +01:00
0eeed94772 feat(contribution): free-to-paid uses cadence plan id, persists billingCadence 2026-04-18 17:37:35 +01:00
e8c81cf062 feat(contribution): paid-to-paid tier swap via recurringAmount PATCH 2026-04-18 17:32:22 +01:00
4b5ea9bbd8 fix(helcim): restore subscriptionStartDate on paid-tier activation 2026-04-18 17:28:05 +01:00
8d43804c7f feat(helcim): create subscription by cadence with recurringAmount
Replace tier-based plan lookup with cadence-keyed lookup, compute
recurringAmount via getTierAmount, persist billingCadence on member.
Delete both manual-fallback blocks; Helcim failure now surfaces as 500.
2026-04-18 17:25:14 +01:00
be0e6e7699 refactor(config): cadence-keyed plan id, add getTierAmount, drop per-tier helcimPlanId 2026-04-18 17:19:05 +01:00
35197c465b feat(schemas): accept cadence field on subscription + contribution updates 2026-04-18 17:16:09 +01:00
47f2d666dd fix(helcim): use Number(id) in wrapped PATCH /subscriptions body 2026-04-18 17:14:24 +01:00
de4bfdcc16 feat(member): add billingCadence field to schema 2026-04-18 17:12:45 +01:00
4f567e9586 refactor(helcim): wrapped PATCH body, first-activation welcome email guard
Moves updateHelcimSubscription to the live-verified wrapped shape
(PATCH /subscriptions { subscriptions: [{ id, ...payload }] }), adds a prior-
status check so sendWelcomeEmail only fires on pending_payment to active
transitions, short-circuits get-or-create-customer when a valid
helcimCustomerId is already on file, and replaces member.save() Slack-status
writes with findByIdAndUpdate({ runValidators: false }) to avoid save-time
validator pitfalls.
2026-04-18 17:06:30 +01:00
15329e3e84 refactor(events): gate member benefits on hasMemberAccess
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
2026-04-18 17:06:17 +01:00
c5e901ed24 feat(signup): community guidelines agreement and policies routes
Introduces /community-guidelines and /policies/{privacy,terms,[slug]} pages,
swaps the signup/invite checkbox from agreedToTerms to agreedToGuidelines,
adds Member.agreement.acceptedAt, and stamps the field when a Helcim
customer is created.
2026-04-18 17:06:10 +01:00
2c834da40a chore: remove placeholder payment block from members/create 2026-04-17 17:16:32 +01:00
3ba633cce2 chore: remove dead guest-register event route
The /api/events/[id]/guest-register endpoint has no production
callers: it's superseded by tickets/purchase.post.js, which
handles guest Member upsert via status:"guest" when
body.createAccount is true. Drops the route file, its
source-assertion tests, guestRegisterSchema, and its validation
coverage.
2026-04-17 16:36:34 +01:00
6f9e6a3d98 feat(events): guest accounts for public event registration
Non-members who register for an event now get a persistent identity:
with consent, a status:"guest" Member is upserted and an auth cookie is
set so the "You're Registered" state survives a page refresh.

Tiered auto-login matches passwordless-auth norms — auto-login is only
safe when the account holds no privileges:
- New email → create guest + cookie
- Returning guest → cookie
- Existing non-guest (active/pending/etc.) → attach ticket only, no
  cookie, confirmation email includes a sign-in link

Guests are gated on status === "guest", so admin/middleware code that
keys on status === "active" naturally excludes them. Guests are also
treated as non-members for ticket pricing/validation to prevent picking
up member-only pricing on their second registration.
2026-04-16 21:23:31 +01:00
7e7672d52b New SiteContent. 2026-04-16 21:11:14 +01:00
02222a5c16 Copy and layout improvements. 2026-04-16 21:11:05 +01:00
39eb9e039a fix(auth): auto-submit OIDC logout form to eliminate xsrf desync
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Users clicking sign-out in the wiki were getting 'xsrf token invalid'.
The old logoutSource extracted the xsrf from oidc-provider's form into
a separate short-lived cookie and bounced through /auth/logout-confirm,
but that dance kept desyncing — the xsrf on the eventual submit didn't
always match the session state on /oidc/session/end/confirm.

Drop the custom confirmation page and auto-submit oidc-provider's own
form inline from logoutSource. The xsrf stays inside the original form
HTML the provider generated, so the validation is guaranteed to match.
Clicking sign-out in the wiki is already confirmation enough.

Also clear the Ghost Guild auth-token cookie in postLogoutSuccessSource
so signing out of the wiki fully signs the user out rather than leaving
a stale ghostguild.org session behind.
2026-04-15 18:26:51 +01:00
3ad22a8b67 fix(auth): survive missing OIDC interaction cookie on magic-link click
Some checks failed
Test / vitest (push) Failing after 6m13s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 3s
Clicking the wiki magic-link email was producing SessionNotFound:
'interaction session id cookie not found' from
provider.interactionFinished, because that call requires the short-lived
_interaction cookie to be present on the request. It isn't, when:

- the user clicks the email on a different device or browser
- the interaction cookie already expired
- the user is in private/incognito browsing

Those unhandled errors previously bounced to /coming-soon via the
coming-soon middleware, stranding users on the pre-register page.

Instead of relying on the interaction cookie at the magic-link step:

1. Verify the JWT, look up the member, set the auth-token cookie.
2. Redirect the user back to https://wiki.ghostguild.org.
3. Outline re-initiates OIDC, which creates a fresh interaction whose
   cookie IS present on the same request, and [uid].get.ts SSOs the user
   in via the auth-token cookie we just set.

Also swap the createError throws for sendRedirect to /auth/oidc-error so
token/member/status failures land on the styled error page rather than
Nitro's default unhandled-error response.
2026-04-15 18:18:33 +01:00
1e9e9c4d97 fix(auth): stop wiki login loop to coming-soon and surface non-member state
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
Members (and pre-registrants) hitting wiki.ghostguild.org were getting bounced
to /coming-soon with a "Pre-Register" link, even when the OIDC flow was
working correctly.

- Allowlist /auth/oidc-error, /auth/logout-confirm, /auth/logout-success,
  and /verify in the coming-soon middleware so OIDC errors and main-site
  magic links stop redirecting to the pre-register page.
- Raise OIDC Interaction TTL from 10m to 15m so it outlives the magic-link
  JWT and legitimate members don't hit expired-interaction errors when they
  click the email a few minutes late.
- Differentiate the "email isn't a registered member" response on the wiki
  login route and show a dedicated "Not a member yet" state with a
  pre-register link and contact email, instead of the misleading
  "Check your inbox" that silently failed.
2026-04-15 17:55:55 +01:00
2394248d53 Updates
Some checks failed
Test / vitest (push) Failing after 6m9s
Test / visual (push) Has been skipped
Test / playwright (push) Has been skipped
Test / Notify on failure (push) Successful in 2s
2026-04-15 17:45:09 +01:00
28040f44f4 refactor(board): atomic delete + query limit + composable cleanup
Some checks failed
Test / vitest (push) Failing after 7m17s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 1s
Delete uses findOneAndDelete with author match (no TOCTOU window);
existence check only runs on miss to distinguish 403 vs 404. Posts
list capped at 200. Drop unused resolveTagChannel and refreshParams;
route slack URL building through the composable's slackUrl helper.
2026-04-15 12:47:53 +01:00
7292b11c0b feat(member): account/profile polish + tier upgrade flow
- Timezone: curated USelectMenu dropdown (app/config/timezones.js), preserves unknown saved values
- Profile save now uses useToast() for success/error; remove inline save banner
- Nav onboarding dot nudged down 1px for optical alignment with lowercase text
- Onboarding: skip a suggestion with POST /api/onboarding/track {skip}; member.onboarding.skipped map; does not affect graduation
- CirclePicker takes :saved-value so 'Current' badge stays until save completes
- PrivacyToggle is binary (USwitch labeled Private); member schema enum reduced to ['members','private']; zod coerces legacy 'public'
- New /member/payment-setup page: HelcimPay $0 verify + update-contribution, wired from account.vue via requiresPaymentSetup redirect
- Helcim portal: NUXT_PUBLIC_HELCIM_PORTAL_URL env + account.vue 'Manage billing in Helcim' link
- Migration script: scripts/migrate-privacy-public-to-members.js
2026-04-14 20:35:37 +01:00