2026-05-09 — Events shipped end-to-end, Node + oxc migration

Summary

Shipped the event-discount-codes feature on Guild (PR #112). Members on a tier with eventDiscount > 0 claim a single-use Hi.Events promo code per event, redeem at Hi.Events checkout, abuse logged on email mismatch. Got it through Copilot review and live on Railway staging end-to-end (paid order, free order, abuse path). Prod startup tripped over Payload’s dev-mode marker; fixed by deleting the dev row from payload_migrations on prod postgres.

While testing, started the design conversation for wiring event ticket purchases into the points/XP system. Landed on award-on-check-in (Option A) over award-on-order. Scoped the rest as follow-up. Captured tech-debt items to revisit.

Events → points design (planned, not implemented)

Award points + XP when Hi.Events fires checkin.created, not order.created. Why:

  • Closes the buy → earn points → redeem store credit → refund abuse vector. No points exist before attendance, so refund reversal is a non-issue for the common case.
  • Aligns with the event ticket policy: late arrivals can’t join, no-shows lose the seat. They lose the chance to earn, fair.
  • DM cancellations issue full refunds before any check-in fires, so nothing to reverse. DM reschedules push the check-in to the new date.

Award shape, mirroring purchases:

  • Paid event check-in: cents × tier.earnMultiplier for points, XP equals points (with active buff multiplier).
  • Free event check-in: flat 100 points + 100 XP, same as kiosk visit.

source: 'event' on the loyalty-transaction (distinct from 'purchase', since Hi.Events tickets don’t write a Purchase row). Stash order amount + Hi.Events attendee/order IDs in metadata so a future refund handler can reverse.

Refund handling (order.refunded, attendee.cancelled) punted to a follow-up. Under the policy, post-check-in refunds are rare enough to handle manually until the webhook is wired.

Ticket transfers and points (clarified with Carrie)

  • All tickets non-refundable; only refundable when DM or bookstore cancels.
  • Tickets are transferrable, except within 48 hours of the event.
  • One purchaser can buy multiple seats; Hi.Events tracks attendees separately from the purchaser.
  • “RSVP w/ book” exists as a bundled product. No inventory tracking, just a named SKU on the Hi.Events side.

Implication for award-on-check-in: when A buys and transfers to B, B is the one who checks in, so B gets the points. A who paid does not. This is the right outcome — it closes the “buy-then-transfer to farm points on a friend’s account” abuse vector. The points follow the seat, not the wallet.

For event-to-event transfers, Carrie manually adds the attendee to the new event’s attendee list in Hi.Events admin. All transfers so far have been 1:1 in pricing; no discount codes involved; no price-mismatch cases yet.

Open question: does Hi.Events’ manual attendee-add carry the original order_id? If yes, the award-on-check-in flow resolves the paid amount via attendee.order_id → original order, and the right amount of points lands on the right person regardless of which event they actually attend. If standalone (no order_id), fall back to free-event treatment (flat 100/100) rather than crashing the webhook.

Future complication, not today’s problem: discount codes on transferred tickets, or price-mismatched transfers, or comp tickets. The base model (total_gross / attendees.length, scoped by order_id) handles everything as long as Hi.Events keeps the order linked. Once it doesn’t, we either follow Carrie’s manual workflow (no points) or extend the model.

Multi-attendee orders

Use even-split across attendees on the order: perAttendeeCents = totalGrossCents / order.attendees.length. Robust to promo codes (already factored into total_gross). Fuzzy for hypothetical mixed-product orders (e.g. one attendee with a “RSVP w/ book” and one without on the same order); we over-credit one and under-credit the other. Carrie hasn’t seen this in practice, so acceptable for v1.

Tech debt to revisit

1. Bump Node baseline from 22 to 24

Local on 24, Dockerfile and CI on 22. Both are LTS. Bumping is a separate task — needs a Docker image rebuild and a regression pass. Left CI/prod aligned on 22 to ship the events PR cleanly.

Files involved: Dockerfile, .github/workflows/ci.yml, package.json engines.

2. Evaluate biome → oxc migration

oxc-project’s linter and formatter have been gaining momentum. Worth a comparative pass: speed, rule coverage vs our biome config, ecosystem compatibility (especially with our Next.js / Payload setup). Not urgent, but biome’s pace has slowed and oxc is clearly aiming at the same surface.

3. Prod keeps ending up in dev mode

src/payload.config.ts sets prodMigrations: migrations, so Payload applies pending migrations on container start. If the prod DB’s payload_migrations table has a dev-pushed marker, Payload prompts (“data loss will occur, continue?”) and the container deadlocks. Manually deleting the dev row clears it.

The prompt is intentional: when the schema is in a weird state, a human should look. The real question is why prod is getting marked dev-pushed at all. Suspect: one of the backfill / utility scripts in scripts/ is initializing Payload without the right env or in a way that triggers dev push against the prod DB. Worth tracing once it happens again — capture which script ran and against which DATABASE_URL.

4. Points include tax (and fees)

Both Square purchases and Hi.Events event check-ins compute points on the all-in paid amount, including tax and processing fees. Members technically earn loyalty value on the tax portion. Cleaner would be points on the merchandise subtotal only, e.g. Hi.Events total_before_additions and Square’s pre-tax money.

Not fixing now — too cross-cutting to bundle with the events PR. Costs members slightly less than the all-in basis would; for our typical 20 ticket at NJ 6.625%, we’re talking ~150–300 extra points (≈ 0.03 of store credit). Tolerable until/unless the math becomes load-bearing.

5. Per-attendee idempotency is read-then-write

In processEventCheckIn, the per-attendee award uses a find-then-create pattern keyed on metadata.hiEventsAttendeeId. Two concurrent checkin.created webhooks for the same attendee can both see no prior transaction and both award points.

Not solving in the events-points PR. Hi.Events doesn’t deliver concurrent webhooks at meaningful rates for our shop’s volume, the worst case is a single duplicate award per attendee, and the math is small enough to spot and reverse manually if it ever fires. Proper fix would require denormalizing hiEventsAttendeeId into a real indexable column on loyalty-transactions (JSONB metadata can’t carry a unique constraint cleanly) plus a unique index. Worth doing if/when we see a duplicate land in prod.

What actually shipped today

  • #112 self-serve event discount codes — merged. Tier members claim a single-use Hi.Events promo code per event, redeem at checkout, abuse logged on email mismatch. Staging-verified paid + free + abuse paths. Also produced a small follow-up review pass with Copilot (alt text, lowercase code generation, migration down ordering, race orphan handling on the Hi.Events promo create, FK cascade for member/shop deletes).
  • #113 events points + xp on check-in — merged. processEventCheckIn now fetches the order via getOrder(), computes per-attendee paid amount as total_gross / attendees.length, awards cents × tier.earnMultiplier points + matching XP for paid attendance, flat 100/100 for free events. Per-attendee idempotency keyed on hiEventsAttendeeId. Verified end-to-end on staging with a real $10 ticket on Gold (2.5x → 2500 points). Copilot’s review surfaced a real bug (default Payload depth was populating shop.organization as an object, then as number made it NaN) — fixed in the same branch with overrideAccess defense-in-depth on the unauthenticated webhook path.
  • #114 filed: reconcile points basis to subtotal (exclude tax + fees) for both Square and Hi.Events. Parked. Math is small (~$0.02 per ticket of leakage) and the refund-reversal path needs care, so it waits for the next loyalty-system pass.
  • #115 Node 24 + oxc migration — draft PR. Bumps Node from 22 → 24.15 across Dockerfile, CI, engines, .nvmrc, with CI now reading node-version-file: .nvmrc so the version is single-sourced. Drops biome, adds oxlint + oxfmt. Format defaults switched to convention (double quotes, semis, trailing commas, 100-width) — 244 files reformatted in one sweep. Lint is ~270ms across 213 files vs biome’s ~1s. Railway built and deployed cleanly.

Voidzero ecosystem assessment (out of curiosity)

Rolldown 1.0 (May 2026): Vite-default bundler. We’re a Next.js app, so the prod bundle path doesn’t change. Vitest is on Vite 7 transitively; we’ll get Rolldown in test runs whenever vitest bumps to Vite 8. No action.

Vite+ (vp CLI): wrapper that orchestrates runtime + package manager + lint + format + test under one command. Aimed at orgs with many repos / many engineers where standardizing the tooling story pays off. For Guild (single Next app, scripts already coordinated through package.json), it’s overhead without gain.

Where it could matter elsewhere: emporium is a real workspace (Next storefront + Medusa backend + Payload), already on ESLint, would benefit from oxlint via @oxlint/migrate (the automated path that didn’t apply to Guild because we were on Biome). Vite+ still wouldn’t fit there either — mixed frameworks per package mean a Vite-centric orchestrator doesn’t unify everything; Turborepo is the more natural pick if/when Carrie wants unified workspace commands.

Conclusion: oxlint + oxfmt is the only voidzero piece that’s actually load-bearing for us today, and we just adopted it. The rest tracks itself transitively.