Commit graph

102 commits

Author SHA1 Message Date
da5e7efcb7 fix(launch-flow): auto-link /join signups to existing PreRegistration
When a /join submitter's email matches a pending/selected/invited
PreRegistration, mark the pre-reg as accepted and link memberId to the
new Member. Prevents the same person from appearing as both an active
member and an unaccepted pre-registrant. Silent — no email, no UI.

Adds the PreRegistration mock to helcim-customer and free-signup-flow
test suites, since both invoke the customer handler at runtime.
2026-04-30 14:43:02 +01:00
26791cc0e3 chore(simplify): trim narrating comments and dedup test body
Test file: drop step markers, regression explainers, and the lead
comment block that restated the contract; hoist the shared subscription
request body to a const; move Member mock defaults into the test that
uses them. Two it() cases unchanged.

Events page: drop WCAG comment that narrated what the
.past-toggle:focus-visible selector already says.
2026-04-29 21:50:00 +01:00
6527bbbe4e test(api): cover free-signup → subscription bridge-cookie hand-off
Two tests guarding the regression where /api/helcim/customer skipped
setPaymentBridgeCookie for $0 signups and left the user unable to
complete activation. Second test confirms the auth gate on
/api/helcim/subscription still rejects fresh unauthenticated calls.
2026-04-29 21:00:27 +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
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
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
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
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
208638e374 feat(launch): security and correctness fixes for 2026-05-01 launch
Day-of-launch deep-dive audit and remediation. 11 issues fixed across
security, correctness, and reliability. Tests: 698 → 758 passing
(+60), 0 failing, 2 skipped.

CRITICAL (security)

Fix #1 — HELCIM_API_TOKEN removed from runtimeConfig.public; dead
useHelcim.js deleted. Production token MUST BE ROTATED post-deploy
(was previously exposed in window.__NUXT__ payload).

Fix #2 — /api/helcim/customer gated with origin check + per-IP/email
rate limit + magic-link email verification (replaces unauthenticated
setAuthCookie). Adds payment-bridge token for paid-tier signup so
users can complete Helcim checkout before email verify. New utils:
server/utils/{magicLink,rateLimit}.js. UX: signup success copy now
prompts user to check email.

Fix #3 — /api/events/[id]/payment deleted (dead code with unauth
member-spoof bypass — processHelcimPayment was a permanent stub).
Removes processHelcimPayment export and eventPaymentSchema.

Fix #4 — /api/helcim/initialize-payment re-derives ticket amount
server-side via calculateTicketPrice and calculateSeriesTicketPrice.
Adds new series_ticket metadata type (was being shoved through
event_ticket with seriesId in metadata.eventId).

Fix #5 — /api/helcim/customer upgrades existing status:guest members
in place rather than rejecting with 409. Lowercases email at lookup;
preserves _id so prior event registrations stay linked.

HIGH (correctness / reliability)

Fix #6 — Daily reconciliation cron via Netlify scheduled function
(@daily). New: netlify.toml, netlify/functions/reconcile-payments.mjs,
server/api/internal/reconcile-payments.post.js. Shared-secret auth
via NUXT_RECONCILE_TOKEN env var. Inline 3-retry exponential backoff
on Helcim transactions API.

Fix #7 — validateBeforeSave: false on event subdoc saves (waitlist
endpoints) to dodge legacy location validators.

Fix #8 — /api/series/[id]/tickets/purchase always upserts a guest
Member when caller is unauthenticated, mirrors event-ticket flow
byte-for-byte. SeriesPassPurchase.vue adds guest-account hint and
client auth refresh on signedIn:true response.

Fix #9 — /api/members/cancel-subscription leaves status active per
ratified bylaws (was pending_payment). Adds lastCancelledAt audit
field on Member model. Indirectly fixes false-positive
detectStuckPendingPayment admin alert for cancelled members.

Fix #10 — /api/auth/verify uses validateBody with strict() Zod schema
(verifyMagicLinkSchema, max 2000 chars).

Fix #11 — 8 vitest cases for cancel-subscription handler (was
uncovered).

Specs and audit at docs/superpowers/specs/2026-04-25-fix-*.md and
docs/superpowers/plans/2026-04-25-launch-readiness-fixes.md.
LAUNCH_READINESS.md updated with new test count, 3 deploy-time
tasks (rotate Helcim token, set NUXT_RECONCILE_TOKEN, verify
Netlify scheduled function), and Fixed-2026-04-25 fix log.
2026-04-25 18:42:36 +01:00
53331cc190 fix(events): gate members-only events in calculateTicketPrice
Legacy events (tickets.enabled=false) with membersOnly=true were
returning a free guest ticket for unauthenticated callers, causing
GET /api/events/[id]/tickets/available to report available:true. The
UI then rendered the registration form and register.post.js 403'd on
submit. Short-circuit early when membersOnly && !hasMemberAccess so the
availability endpoint's existing null-ticketInfo branch surfaces the
correct "members only" reason.
2026-04-20 20:12:24 +01:00
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
6a6c567fd5 test(helcim): mock text() not json() to match helcimFetch contract
Some checks failed
Test / vitest (push) Successful in 12m42s
Test / playwright (push) Failing after 9m54s
Test / visual (push) Failing after 9m32s
Test / Notify on failure (push) Successful in 2s
2026-04-20 14:01: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
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
64a31b51a5 test(server): update member tests for contributionAmount rename 2026-04-19 18:55:46 +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
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
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
03eee45cbd refactor(contributions): tighten requiresPayment contract; use findLast 2026-04-19 18:10:57 +01:00
62c606b30a feat(contributions): rewrite client config as preset-based helpers 2026-04-19 18:07:43 +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
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
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
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
37a58cb0eb feat(member): pending_payment retains access, soften status copy
pending_payment now grants the same RSVP/peer-support capabilities as active,
and status banner/label copy is rewritten to be non-threatening ("Setting up
payment", "Paused", "Closed"). Aligns member-facing copy across the account
page with the capability model.
2026-04-18 17:06:22 +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
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