Pre-fix (before f34b062 / 4e1888a) prod may contain drop-in
registrations on pass-only series events. Defer audit + remediation
until deploy time; local was scrubbed separately on 2026-04-20.
Collapse completed launch sections (receipts Phase 1, cadence UX,
contribution-amount manual tests) into one-liners; move them to the
archive memory. Move the three known post-launch gotchas to their own
subsection. Ignore the local one-off preregistration dump script.
Admin event create/update forms submit empty strings for unset numeric
and date fields (maxAttendees, registrationDeadline, ticket quantity,
early-bird pricing), which Zod was rejecting. Preprocess empty strings
to undefined so the existing optional/nullable validators accept them.
Self-cancel endpoint now rejects paid registrations (public, series_pass,
or paid member tickets) with a 403 pointing to /policies/refunds. Free
and $0-member registrations still self-cancel as before. Adds the
refunds policy page referenced in the error message.
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.
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.
Flips the P1 section from 'none of it is implemented' to a shipped/remaining
breakdown citing commits bf5a333..91711aa, and adds a one-line current-state
bullet pointing at the unmerged feature branch.
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.
Captures the three post-Phase-1 deploy steps: run
reconcile-helcim-payments.mjs against prod Mongo after the new code
is serving, disable the default Helcim confirmation email for plans
50302 + 50303 (Branch B — we send our own via Resend), and run a
real staging test charge to verify the Payment doc + single
CRA-compliant confirmation email.
Adds a small paragraph directly below the tier list stating the
Baby Ghosts Studio Development Fund charity status, noting that
Canadian taxpayers can claim contributions, and that setup for
receipts happens after joining. Styled in parallel to
.solidarity-note (12px, --text-dim, 1.65 line-height) so it reads as
a bullet, not a banner.
Scope is /join only — /accept-invite and /member/account copy is
untouched per spec §3.
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.
Iterates every Member with helcimCustomerId, pulls their recent
transactions via listHelcimCustomerTransactions, and upserts a Payment
row per tx using the shared upsertPaymentFromHelcim helper (idempotent
via unique helcimTransactionId index). No confirmation emails are
sent during reconciliation.
Dry-run by default; pass --apply to write. Uses:
- Launch-day backfill for the ~34 pre-existing members.
- Nightly cron/scheduled function post-launch to catch recurring
Helcim charges that bypass the synchronous write paths in
subscription.post.js and update-contribution.post.js.
Written as .mjs rather than .cjs (scripts/*.js is gitignored; .cjs
would need dynamic imports for ESM server utils). Shims Nitro's
useRuntimeConfig + createError globals so helcim.js loads outside
the Nitro runtime.
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.
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.
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.
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.
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.
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.
Branch merges and 7/9 manual tests are done — moved to archive. Live
doc now only carries open work: charitable receipts Phase 1, prod
contribution-amount migration + Helcim plan env vars, and two manual
tests (pre-registrant invite, contribution-amount end-to-end). Both
remaining tests now include setup, test steps, assertions, and the
file references needed to complete them without additional context.
Un-defer pre-registrant invite manual test (refactor landed), add
contribution-amount end-to-end manual test, and list the cosmetic
cleanup items (admin column, dead TierPicker, stale comments) in the
post-launch backlog.
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).
Guest signup, mobile responsive, WCAG contrast, and in-app payment
history all verified via tunnel. Payment history's per-row receipt
link requirement accepted as satisfied by the 'Advanced billing in
Helcim' escape hatch (Helcim's card-transactions API doesn't expose
per-row receipt URLs). Also corrects the mobile breakpoint note —
chrome sidebar hides at 768px, in-page columns collapse at 1024px.
The .circle-desc text used --text-faint, which failed WCAG AA on the
selected/hover tile surfaces (4.01:1 light / 4.31:1 dark). Promote to
--text-dim to clear 4.5:1 against all tile states.
Add Payment history section (live-read from Helcim, with loading/empty/error states)
and Change card flow (HelcimPay.js zero-dollar auth -> POST /api/helcim/update-card)
to /member/account. Relabel Helcim portal link to "Advanced billing in Helcim →"
and demote it to a secondary link at the bottom of the billing group.
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.
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).
- 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).