Commit graph

454 commits

Author SHA1 Message Date
1da76b11cb fix(series): replace phantom Tailwind on SeriesPassPurchase
Error state and main registration card swap bg-ember-*/border-ember-* and
bg-guild-*/border-guild-* utilities for design tokens in a scoped style
block. Error state uses the codebase's --ember + 8% color-mix pattern;
registration card uses --surface + dashed --border per the zine spec.
2026-04-29 20:22:35 +01:00
350d6c219c fix(series): replace phantom guild Tailwind on EventSeriesBadge
Swap bg-guild-*/border-guild-*/text-guild-* utility classes for design tokens
in a scoped style block. Drops rounded-* per the no-rounded-corners rule and
uses dashed borders for the structural block per the zine spec.
2026-04-29 20:22:30 +01:00
05c47c4499 docs(backlog): close out admin layout token migration as stale
Verified clean 2026-04-29: grep for guild-[0-9]|candlelight-[0-9]|ember-[0-9]
across app/layouts/, app/pages/admin/, and app/components/admin/ returns zero
matches. All admin surfaces already use design tokens.
2026-04-29 20:22:25 +01:00
59d2be2df8 docs(backlog): close out a11y triage items
Strike two stale entries (verified 2026-04-29) and the OIDC routing
quirk (fixed in 23154ff).
2026-04-29 20:10:38 +01:00
23154ff232 fix(oidc): disable devInteractions so custom interactions.url runs in dev
oidc-provider's devInteractions is a quick-start scaffold that, when
enabled, mutates configuration.url to its own urlFor('interaction')
helper — emitting /interaction/UID instead of our /oidc/interaction/UID.
That made /oidc/auth redirect to a 404 in local dev and forced a stale
TODO entry. We already have our own interaction handler at
server/routes/oidc/interaction/[uid].get.ts, so devInteractions is
unnecessary; disabling it makes dev match prod and clears the
oidc-provider warning "your configuration is not in effect".
2026-04-29 19:59:49 +01:00
a69c9d9b49 fix(uploads): replace phantom Tailwind palette with design tokens
Sibling sweep to dc2becf: NaturalDateInput.vue and ImageUpload.vue used
candlelight-/ember-/guild-* utility classes that aren't defined in the
project's Tailwind palette and rendered as no-ops. Swapped to inline
styles using --candle, --ember, --text-dim/faint/bright, --border,
--input-bg, --surface. Drag-state and parsed-date notices follow the
color-mix(... 15%) + 1px solid pattern from dc2becf.
2026-04-29 19:46:59 +01:00
dc2becf63e fix(events): replace phantom candlelight Tailwind with --candle var 2026-04-29 18:30:29 +01:00
e19b16a5cc chore(members): TODO comment for cadence-switch sub-replacement flow 2026-04-29 18:26:40 +01:00
e756170884 feat(admin): warn that contribution edit doesn't sync Helcim 2026-04-29 18:25:59 +01:00
7e44809a83 fix(events): grid-align consent hint, drop magic 24px padding 2026-04-29 18:22:45 +01:00
f66455eda5 fix(tickets): gate memberSavings on hasMemberAccess
Previously the publicTicket comparison block ran whenever a Member record
existed, which surfaced "$0 saved" for cancelled/suspended/guest accounts.
Use the canonical hasMemberAccess helper so only active/pending_payment
members see the savings comparison.
2026-04-29 17:54:58 +01:00
955217a941 chore(admin): rename pending_payment label and tier→contribution
Backlog cleanup from docs/LAUNCH_READINESS.md:
- B4: admin status filter + form options + STATUS_LABELS now read
  "Payment setup incomplete" so admins stop conflating with membership state
- CSV import preview header "Tier" → "Contribution"
- handleUpdateTier → handleUpdateContribution on /member/account
- update-contribution error log "tier" → "amount"
2026-04-29 17:54:53 +01:00
d15458b30a chore(slack): remove dead invite path, archive checkSlackJoins poller
Some checks failed
Test / vitest (push) Successful in 12m6s
Test / playwright (push) Failing after 9m39s
Test / visual (push) Failing after 9m28s
Test / Notify on failure (push) Successful in 2s
Wave-based onboarding makes the auto-invite + polling path obsolete.

- Removes SlackService.inviteUserToSlack — admins now send invites
  through Slack's UI and flip the flag in our admin endpoint.
- Removes the slack_invite_failed admin alert + its detector. The
  alert no longer has a meaningful trigger (we don't attempt invites).
- Archives server/utils/checkSlackJoins.js (and its test) under
  _archive/ in case the polling pattern is needed again post-pilot.
- Deletes the Nitro plugin that scheduled checkSlackJoins on boot
  + hourly. Nothing in nitro.config / nuxt.config / package.json
  registered it elsewhere.
- Drops the slack_invite_failed branch from adminAlerts.test; the
  enum slug stays in adminAlertDismissal so historical dismissal
  rows continue to validate.

notifyNewMember (vetting-channel notification) and findUserByEmail
(used by the auto-flag helper) are retained.
2026-04-29 12:34:21 +01:00
7b326f879d feat(dashboard): one-line note for active members awaiting Slack invite
Renders only when status==='active' && !slackInvited. Hidden for
pending_payment, suspended, cancelled, guest, and any member already
flagged as invited. Lives inside the existing ClientOnly tree at the
top of the dashboard so it never SSRs.

Plain inline text in the welcome region — no banner, no callout. The
2–3 week window is admin-side workflow; the copy avoids cohort/wave
language.
2026-04-29 12:26:51 +01:00
c2999810c6 feat(admin/members): mark-as-Slack-invited button + date display
Replaces the placeholder Slack-invite handler with a call to the new
PATCH /api/admin/members/:id/slack-status endpoint. Status labels are
reworded to match reality (no Slack API call from this app):

- Pending → Not yet invited
- Invited → Invited <slackInvitedAt>
- Action button copy → 'Mark as Slack invited'
- Removes slackInviteStatus reads from the member detail page (the
  remaining repo-wide sweep lands in the cleanup task).
2026-04-29 12:25:18 +01:00
0981596ea2 feat(admin): PATCH /api/admin/members/:id/slack-status
Endpoint that flips a member's slackInvited flag manually after the
admin has actually sent the Slack invitation through Slack's UI. No
Slack API call is made from this app.

- Body validated via Zod literal-true schema (no undo path for the
  pilot — admins correct mistakes in the database if needed).
- Idempotent: re-marking an already-invited member is a no-op,
  preserving the original slackInvitedAt and not duplicating the
  activity log entry.
- Activity log: slack_invited_manually, actor = admin from
  requireAdmin, subject = the target member.
2026-04-29 12:23:07 +01:00
55029e7eb7 feat(activation): wire autoFlagPreExistingSlackAccess into self-serve paths
Replaces the per-file inviteToSlack helpers with a single auto-flag
call. Self-serve activation paths now check for pre-existing workspace
membership (silent on miss) instead of attempting an admin-only invite.

- helcim/subscription.post.js: removed local inviteToSlack; both
  free- and paid-tier activation branches now call the helper, then
  notifyNewMember with the canonical 'manual_invitation_required' arg.
- members/create.post.js: same shape — helper + canonical notify arg.
- invite/accept.post.js (free-tier branch): added the helper call after
  member creation. Free-tier had no prior Slack call (audit confirmed);
  paid-tier remains untouched and activates via the Helcim webhook.

Admin-created and CSV-imported members intentionally do NOT call the
helper — admins flip the flag manually after sending the invite.

Test stub for autoFlagPreExistingSlackAccess added to server setup.
2026-04-29 12:21:12 +01:00
b1d8cb1966 feat(slack): autoFlagPreExistingSlackAccess helper
Best-effort lookup of an activating member's email in the Slack
workspace. On a hit, flips slackInvited:true and stamps slackInvitedAt
without sending a fresh invite. Races against a 3s timeout and swallows
all errors so activation never blocks on Slack.

- Promotes SlackService.findUserByEmail from private to public so the
  helper can call it without a wrapper.
- New activity-log action: slack_access_auto_detected (actor = subject).
- Idempotent: short-circuits when slackInvited is already true.

Callers wired in next commit.
2026-04-29 12:13:59 +01:00
2f6a92ac61 refactor(member): replace slackInviteStatus with slackInvitedAt
Schema change for wave-based Slack onboarding. The string enum
slackInviteStatus is replaced with a simple slackInvitedAt: Date —
boolean slackInvited is the source of truth, the date records when.

Call sites that flip slackInvited:true must stamp slackInvitedAt
in the same update (no pre-save hook, per findByIdAndUpdate convention).

Sweeps of remaining slackInviteStatus references land in later tasks.
2026-04-29 12:11:25 +01:00
3c49317437 chore: remove dead TierPicker + strike resolved gotchas
Some checks failed
Test / vitest (push) Successful in 10m57s
Test / playwright (push) Failing after 9m32s
Test / visual (push) Failing after 9m16s
Test / Notify on failure (push) Successful in 2s
TierPicker.vue is a 5-tier preset picker from before the arbitrary-
amount contribution redesign. Zero imports across app/ and server/ —
purely dead code (99 lines).

Strike two LAUNCH_READINESS bullets that describe already-fixed
issues: the "stale tier comment" in useMemberPayment.js (no `tier`
references remain in that file), and the SeriesPassPurchase auto-
refresh gotcha (fetchPassInfo() already runs after the success path
at line 318).
2026-04-27 21:07:17 +01:00
be24ae32fb fix(toast): rename Nuxt UI 4 toast.add timeout → duration
Some checks failed
Test / vitest (push) Successful in 11m3s
Test / playwright (push) Failing after 9m29s
Test / visual (push) Failing after 9m26s
Test / Notify on failure (push) Successful in 3s
Nuxt UI 4's Toast component reads `duration` (default 5000ms), not
`timeout` — the property was silently ignored. Behavior unchanged
since 5000ms matched the default. Fix the call site to be honest.

Strike the now-resolved gotcha from LAUNCH_READINESS.md.
2026-04-27 19:50:38 +01:00
cf59931814 fix(helcim): read dateBilling on subscription CREATE to populate next-billing cache
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Helcim returns next-charge as `dateBilling` on POST /subscriptions, but the
two CREATE sites were reading `subscription.nextBillingDate`, leaving
`member.nextBillingDate` empty after every signup and free→paid upgrade.
The lazy refresh in subscription.get.js (which already accepts both shapes)
masked it on next account-page load, so renders eventually populated — but
the success response we returned to the client also had `nextBillingDate:
undefined`. Mirror the GET-side resolution at both CREATE sites: prefer
`dateBilling`, fall back to `nextBillingDate`. Existing Number.isNaN guard
unchanged; defensively rejects malformed strings from either field.
2026-04-27 19:44:35 +01:00
3c38333dd1 fix(reconcile): pass customerCode (not helcimCustomerId) to Helcim transactions API
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Has been cancelled
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
2026-04-27 19:31:59 +01:00
4d44e7045c refactor(rate-limit): delegate auth limiting to handlers, add dev bypass
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Main's middleware-level auth limiter (5 req / 5 min, IP-only) duplicated
the handler-level limiter introduced earlier on this branch (5/hr IP +
3/hr per-email, blocks email enumeration across IPs). Drop the
middleware version and let the handlers own it.

Added ALLOW_DEV_TEST_ENDPOINTS bypass to the rateLimit utility so
parallel E2E runs from 127.0.0.1 don't exhaust per-IP/email budgets,
mirroring the existing middleware bypass.

Trimmed the obsolete middleware auth test; handler-level coverage lives
in tests/server/api/auth-{login,verify}.test.js. Switched IP-isolation
test to the payment path so it still exercises the limiter.
2026-04-27 19:18:34 +01:00
c1367ebd29 refactor(helcim): collapse redundant Member queries in subscription.post.js 2026-04-27 19:16:32 +01:00
ac5e979c78 feat(payments): persist helcimCustomerCode + skip getOrCreateCustomer on card-on-file 2026-04-27 19:16:32 +01:00
0a41b30db7 refactor(helcim): normalize listHelcimCustomerCards return shape 2026-04-27 19:16:32 +01:00
5f93d4c2e3 refactor(series): extract loadPublicSeries helper 2026-04-27 19:16:32 +01:00
bd4561fea7 refactor(events): move 'now' into filteredEvents computed 2026-04-27 19:16:32 +01:00
2611a2a973 perf(reconcile): chunked Promise.all in member loop 2026-04-27 19:16:32 +01:00
5432dfe8f2 refactor(payments): extract PAYMENT_METADATA_TYPE constants 2026-04-27 19:16:32 +01:00
0eeb3c351f feat(security): rate-limit auth/login + auth/verify 2026-04-27 19:16:32 +01:00
bafe24b778 chore(tests): replace source-grep tests with handler tests 2026-04-27 19:16:32 +01:00
00073ec52c E2e tests
Some checks failed
Test / vitest (push) Successful in 12m20s
Test / playwright (push) Failing after 9m52s
Test / visual (push) Failing after 9m22s
Test / Notify on failure (push) Successful in 2s
2026-04-27 14:51:25 +01:00
edef1b86be Merge pull request 'Stabilize e2e suite: rate-limit, spec drift, a11y, visual baselines' (#1) from fix/e2e-stabilization-2026-04-26 into main
Some checks failed
Test / vitest (push) Successful in 11m7s
Test / playwright (push) Failing after 9m33s
Test / visual (push) Failing after 9m32s
Test / Notify on failure (push) Successful in 2s
Reviewed-on: #1
2026-04-26 19:16:21 +00:00
0d83003f87 test(visual): regenerate baselines to match current page state
Some checks failed
Test / vitest (pull_request) Successful in 11m51s
Test / playwright (pull_request) Failing after 9m37s
Test / visual (pull_request) Failing after 9m34s
Test / Notify on failure (pull_request) Successful in 3s
23 baselines updated to reflect intentional content evolution. Layouts
and design-system structure are unchanged — diffs are copy edits, refreshed
data, and (for /coming-soon) added pre-register / magic-link affordances.

Driven by: home hero copy + button labels changed; about/events/members
reflect updated content; admin pages reflect current member/event data;
SignupFlowOverlay structure on join; auth-gated routes redirect unauth
visitors to /join (members-mobile, members-desktop snapshots).

Spot-checked: coming-soon, members-mobile, home — all look right.
2026-04-26 18:34:37 +01:00
521efb0890 fix(a11y,test): USelect placeholder contrast + auth logout hydration wait
a11y (main.css):
- Nuxt UI's default placeholder color (text-dimmed = #a6a09b) failed WCAG
  AA contrast on cream (2.43:1) and white (2.58:1) backgrounds, blocking
  axe checks on /member/profile (timezone) and /admin/events/create
  (tags). Override [data-slot="placeholder"] globally to var(--text-dim)
  (#5a5040), comfortably above 4.5:1 on both surfaces.

auth.spec.js (logout):
- Same hydration race as the board/admin-board-channels click tests:
  /admin's sidebar Sign-out @click handler isn't bound when Playwright
  fires the click immediately after admin-tag visibility, so the click
  no-ops and waitForResponse for /api/auth/logout times out.
- Add waitForLoadState('networkidle') after goto so hydration completes
  before the click.
2026-04-26 18:30:32 +01:00
bb0dbfe53e test(e2e): align specs with current page structure
join-flow:
- Form now requires Community Guidelines agreement; tests check the
  checkbox before expecting submit to enable.
- Contribution input is a numeric field with preset chip buttons, not a
  USelect with $0/mo options — fill the input directly.
- Success state lives in SignupFlowOverlay ("Welcome to Ghost Guild!");
  no .success-box exists. Match by heading instead.
- Inline .error-box renders OUTSIDE <form>, so duplicate-email assertion
  uses .signup-flow-overlay .error-box (which is the user-facing error).

member-profile:
- "How you appear to other members" copy was retired; replace with the
  stable "Show in Member Directory" structural label.
- Add waitForLoadState('networkidle') after goto for ClientOnly auth
  hydration so "Edit Profile" reliably appears within timeout.

board:
- Add waitForLoadState('networkidle') after goto so the action-bar's
  "+ New Post" click handler is bound before the test clicks.
- Submit button is named exactly "Post" — disambiguate from "+ New Post"
  buttons with { exact: true }.
- Delete is a two-step in-card confirm (Delete → Confirm), not a native
  browser dialog; drop the page.once('dialog') listener.

admin-board-channels:
- Channel name placeholder is "e.g., coop-formation" (no leading #).
- Slack Channel ID input only appears in the Edit modal (v-if="editingId"),
  not on Create — Slack channel is auto-created server-side. Drop the
  slack ID fill from the Create step.
- Add waitForLoadState('networkidle') before opening the modal.
2026-04-26 18:28:14 +01:00
3f42307c64 fix(rate-limit): bypass middleware when ALLOW_DEV_TEST_ENDPOINTS=true
Parallel Playwright runs (6 workers, all from 127.0.0.1) burned through the
100 req/min generalLimiter budget within the first ~30s, causing every API
call (including /api/dev/test-login and /api/dev/member-login) to return 429
for the rest of the window. Auth helper waitForURL then timed out at 45s with
no redirect ever firing — surfacing as 8 cascading test failures across
auth.spec.js, board.spec.js, and admin-members.spec.js.

The bypass mirrors the existing gate used by /api/dev/* endpoints: the env
var is opt-in and only set in development (.env) or by Playwright's
webServer config. Production never sets it, so rate limiting remains active.
2026-04-26 18:06:32 +01:00
0c489cf2c3 style: underline contributor links + timezone select placeholder color
- join.vue: underline links inside .checkbox-label
- profile.vue: underline .posts-empty-link by default; remove hover-only
  underline rule; tint timezone select placeholder via :deep slot
2026-04-26 17:55:54 +01:00
0f841912e2 fix(helcim): skip HelcimPay verify when a card is already on file
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Failing after 9m18s
Test / visual (push) Failing after 9m24s
Test / Notify on failure (push) Successful in 2s
Helcim refuses paymentType:'verify' for cards already saved on a
customer ("A new card must be entered for saving the payment method"),
breaking every "Complete Payment" retry after a partial-failed signup.

Add GET /api/helcim/existing-card and short-circuit HelcimPay verify in
useMemberPayment + payment-setup.vue when a saved card is found, going
straight to subscription creation. The two existence-check fetches run
in parallel with get-or-create-customer so no extra round-trip latency
in the common path.
2026-04-26 17:27:40 +01:00
e3410c52a5 fix(csp): allow secure.helcim.app for HelcimPay.js
Some checks failed
Test / vitest (push) Successful in 11m5s
Test / playwright (push) Failing after 9m24s
Test / visual (push) Failing after 9m20s
Test / Notify on failure (push) Successful in 3s
The HelcimPay modal loads from secure.helcim.app, but the CSP only
listed myposjs.helcim.com (script/connect) and secure.helcim.com
(frame, likely a stale typo). Add secure.helcim.app to script-src,
connect-src, and frame-src so the join flow's payment modal can load.
2026-04-26 15:59:36 +01:00
210a8d588f fix(build): disable @nuxt/fonts external providers
Some checks failed
Test / vitest (push) Successful in 11m3s
Test / playwright (push) Failing after 9m27s
Test / visual (push) Failing after 9m23s
Test / Notify on failure (push) Successful in 2s
Build was failing on Dokploy when fonts.bunny.net was unreachable from the
build container. Fonts are already loaded at runtime via the explicit
<link rel="stylesheet"> in app.head, so the auto-resolver is redundant —
disable all external providers to remove the build-time network dependency.
2026-04-26 15:22:08 +01:00
04eb33df6e refactor(env): unify required-env validation through useRuntimeConfig
Some checks failed
Test / vitest (push) Successful in 11m10s
Test / playwright (push) Failing after 14m51s
Test / visual (push) Failing after 11m1s
Test / Notify on failure (push) Successful in 3s
validate-env.js now reads all four required vars (MONGODB_URI, JWT_SECRET,
RESEND_API_KEY, HELCIM_API_TOKEN) from useRuntimeConfig() instead of mixing
direct process.env reads with a JWT-only special case. Mongoose and the six
Resend instantiations follow suit. Either bare or NUXT_-prefixed env names
are accepted, so Dokploy no longer needs duplicate entries.
2026-04-26 14:47:02 +01:00
1083a1d260 chore(docker): bump node 20 → 22
Silences oidc-provider's "Unsupported runtime" warning on every boot.
2026-04-26 14:46:55 +01:00
a2a8d945c6 fix(reconcile): connect mongoose before querying members
Some checks failed
Test / vitest (push) Successful in 10m59s
Test / playwright (push) Failing after 10m22s
Test / visual (push) Failing after 10m11s
Test / Notify on failure (push) Successful in 2s
Route was the only DB-using endpoint that didn't call connectDB(); other
routes warm the connection incidentally, but on a freshly-booted container
with no SLACK_BOT_TOKEN the slack-joins plugin skips and nothing else
opens the pool — first reconcile request hung 10s on buffered Member.find()
and returned 500.
2026-04-26 13:47:03 +01:00
0369992cdd fix(docker): add bash + curl to runtime image for Dokploy scheduled tasks
Some checks failed
Test / playwright (push) Blocked by required conditions
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Has been cancelled
Dokploy wraps scheduled-task commands in `bash -c "..."` and the daily
reconcile cron uses curl. node:20-alpine ships neither — first manual
"Run now" failed with `exec: "bash": executable file not found in $PATH`.

apk add --no-cache bash curl adds ~5MB to the runtime image; trivial cost
for the cron use case.
2026-04-26 13:36:13 +01:00
7a626b0a82 fix(csrf): exempt /api/internal/ from double-submit check
Some checks failed
Test / Notify on failure (push) Blocked by required conditions
Test / visual (push) Blocked by required conditions
Test / vitest (push) Successful in 11m1s
Test / playwright (push) Has been cancelled
The reconcile-payments cron POSTs to /api/internal/reconcile-payments with
an X-Reconcile-Token header but no csrf-token cookie/header. The CSRF
middleware was 403ing the request before the route handler could check
the shared secret — breaking Fix #6 (daily reconciliation cron).

Found while wiring the Dokploy scheduled task. The Netlify scheduled
function would have hit the same 403; nobody noticed because the site
hasn't been deployed yet.

Removing CSRF protection from /api/internal/ is safe: every route under
that prefix is machine-to-machine and gates on its own shared-secret
header. CSRF protects against browser-driven cross-origin POSTs, which
isn't the threat model for these endpoints.

Tests: 758 passing (CSRF middleware unit tests still cover the exempt
list shape).
2026-04-26 13:16:11 +01:00
c149fba13a chore(deploy): retarget launch checklist from Netlify to Dokploy on Hetzner
Some checks failed
Test / vitest (push) Successful in 11m42s
Test / playwright (push) Failing after 9m53s
Test / visual (push) Failing after 9m28s
Test / Notify on failure (push) Successful in 2s
Production hosting is Dokploy on Hetzner, not Netlify. The Nitro app
itself is host-agnostic — Dockerfile + Traefik-aware OIDC + xff-aware
rate limiting were already in place — but the Deploy checklist and the
daily reconcile cron were Netlify-specific.

- LAUNCH_READINESS.md: split Deploy checklist into one-time host setup
  + cutover; replace "Netlify scheduled function" with a Dokploy
  Scheduled Task (curl + X-Reconcile-Token); call out the BASE_URL
  exact-match origin gotcha at customer.post.js:11-15 and the
  NODE_ENV=production requirement.
- Delete netlify.toml and netlify/functions/reconcile-payments.mjs.
  The Nitro route at server/api/internal/reconcile-payments.post.js
  stays — it's host-agnostic; only the trigger moves into Dokploy.

No code changes. validate-env.js still hard-fails on missing
MONGODB_URI / JWT_SECRET / RESEND_API_KEY / HELCIM_API_TOKEN at boot.
Tests: 758 passing, 2 skipped, 0 failing.
2026-04-26 12:29:48 +01:00
8e76ce9366 refactor(launch): collapse helcim-pay duplication and use setAuthCookie helper
Some checks failed
Test / vitest (push) Successful in 11m49s
Test / playwright (push) Failing after 9m43s
Test / visual (push) Failing after 9m24s
Test / Notify on failure (push) Successful in 2s
Follow-up to 51230e5. /simplify review surfaced residual duplication
and a timer leak.

- useHelcimPay: extract _initializeTicket(metadata, errorPrefix) to
  collapse initializeTicketPayment + initializeSeriesTicketPayment
  (95% identical bodies). Drop the dead `amount` arg from initialize-
  TicketPayment — server re-derives ticket amounts in initialize-
  payment.post.js and never reads body.amount for ticket types.
  Capture timer ids and clearTimeout on resolve/reject so the 10-min
  payment timer and 5-second observer timer stop leaking after every
  payment.
- EventTicketPurchase: caller updated for the dropped arg.
- verify.post.js: replace inline jwt.sign + setCookie block with the
  setAuthCookie(event, member) helper. verify was the last hand-rolled
  caller after the helper was extracted in 208638e.
- LAUNCH_READINESS: add simplify-pass-followups bullet pointing to the
  six deferred items in docs/TODO.md.

Tests: 758 passing, 2 skipped, 0 failing.
2026-04-25 22:13:24 +01:00