Case Study

6ixBack.
Toronto pickup volleyball.

Hosts post drop-ins, leagues and tournaments. Players reserve spots. The money moves player-to-host by Interac e-Transfer — no platform cut, no merchant account, no payment processor. Which means nothing ever tells the platform who paid. So I built the thing that works it out.

Next.js 16React 19TypeScriptASP.NET CoreEF CorePostgres 16Dockerpnpm monorepo
6ixback.json
role
sole engineer
timeline
Apr – Sep 2026
status
live in production
scale
138k lines · 186 test files
runtimes
Next.js 16 · .NET 10

/// at a glance

138k
// lines of code, two runtimes
37
// postgres tables · 26 live migrations
186
// test files · vitest + xunit
195
// api routes · 17 vertical slices
43
// design-system primitives
0
// payment processors

/// payment reconciliation, not payment processing

Every other booking product I could copy starts from the same assumption: a processor takes the money, then tells you it happened. Take the processor away and the hardest problem in the system is no longer scheduling nine teams across three tiers. It is knowing, with confidence, that a specific stranger paid a specific host for a specific Monday night.

// 01
A signed code, per signup
Every reservation mints a short code derived from the signup itself and signed server-side. It can't be guessed, and it can't be replayed against a different signup. It rides along in the one field a bank transfer actually gives you: the memo.
// 02
The money never touches the platform
The player e-Transfers the host directly and pastes the code in the message. No Stripe. No merchant account, no percentage, no chargeback surface, and nothing in scope for PCI — because there is no card anywhere in the system to put in scope.
// 03
Read the inbox, not the webhook
With the host's consent, the platform reads the host's own inbox over a read-only Google scope they can revoke at any time. It finds the Interac notification, extracts the code and the amount, and matches them to the signup. A wrong amount is rejected rather than guessed at, and a code nobody ever pays expires on a clock instead of holding a spot forever.
// 04
The part nobody sees
A per-game reconciliation view, so when a host asks “why is this one still unpaid” there is an answer instead of a shrug. And a daily job that warns a host before their inbox authorization lapses — because an expiry nobody notices is a payments outage that looks like nothing at all.

The honest trade-off: inbound reconciliation is polled, not pushed, and it always will be. There is no webhook for a bank transfer between two strangers. Everything in the design follows from accepting that instead of wishing it away.

/// architecture

6ixBack system architecturePlayers and hosts reach a Next.js web tier, which holds no database credentials and forwards every read and write to an ASP.NET Core API over an authenticated service call. The API owns Postgres through EF Core. A cron worker, the same container image running in a different role, drives seven jobs on a sixty-second loop and pushes outbound email through Resend and WhatsApp through a self-hosted gateway. Payment reconciliation is a loop: the API polls the host's Gmail inbox over a read-only scope, receives the Interac e-Transfer notification, matches the signed payment code and amount against the signup, and marks it paid.browserplayers · hostsspa · next.jsrsc · server actionscron · same imagebackground workerapi · asp.net core17 vertical slices · 195 routes3 · match signed code + amountpostgres 1637 tables · 26 migrationsresend · emailwaha · whatsappgmail apiread-only scoperequestsservice callno db credentials here60s loop · 7 jobsef core1 · poll host inbox2 · interac email4 · mark paid/// reconciliation loop6ixBack system architecturePlayers and hosts reach a Next.js web tier, which holds no database credentials and forwards every read and write to an ASP.NET Core API over an authenticated service call. The API owns Postgres through EF Core. A cron worker, the same container image running in a different role, drives seven jobs on a sixty-second loop and pushes outbound email through Resend and WhatsApp through a self-hosted gateway. Payment reconciliation is a loop: the API polls the host's Gmail inbox over a read-only scope, receives the Interac e-Transfer notification, matches the signed payment code and amount against the signup, and marks it paid.browser · players + hostsspa · next.jsrsc · actionscron · workersame imageapi · asp.net core17 slices · 195 routes3 · match code + amountgmail apiread-only scopepostgres 1637 tables · 26 migrationsservice callno db credentials60s · 7 jobs1 · poll inbox2 · interac emailef core4 · mark paid
// two runtimes, one credential boundary, and the reconciliation loop picked out in yellow
  • the web tier holds no database credentials — every read and write goes through the API over an authenticated service call, so the API is the only code path that can reach the data
  • the cron worker is the same container image in a different role: seven jobs on a 60-second loop, in a deliberate order, because reminders have to fire before the sweep that cancels unpaid spots
  • those same seven jobs are also HTTP endpoints, so the schedule can live in Vercel Cron or inside the container with no code change — the deployment target is a config decision, not a rewrite
  • anything that must not be lost on the way out — an email, a WhatsApp post — is written to a transactional outbox in the same transaction as the thing that caused it, then drained with retries

/// what it looks like

6ixback.ca/browse
The browse page listing upcoming drop-ins. Each card shows the date, start time, skill level, venue and neighbourhood, a pip row counting spots taken against capacity, and a reserve button with the price. A sidebar explains that unpaid spots cancel three hours before start and that players e-Transfer their host directly, with no card and no service fee.
// browse — desktop and mobile
6ixback.ca/league/standings
A league standings table for a live season, ranked by MMR and showing win-loss record, point differential and points for and against per team, with each team tagged by tier. The header tracks week one of thirteen, nine teams, and the prize pool.
// league standings — desktop and mobile
6ixback.ca/league/matches
The match nights schedule for week one, broken into tiers. Each row gives a twenty-minute slot, the serving and receiving teams, the running set score, which team referees, and a scorecard link that requires signing in to score live.
// match nights — desktop and mobile

/// built to survive its own maintainer

// tests
186 test files, on both runtimes
Unit tests sit beside the pure logic they cover — payment matching, recurrence rules, roster removal, redirect sanitisation. The API suite boots the real application and runs against a genuine Postgres in a throwaway container, rather than pretending an in-memory provider is a database.
// ci
Four workflows, one required gate
Lint, typecheck and unit tests on the web side; the API suite against a Postgres service container. Every change to either package must carry a changeset, enforced as a merge gate — with a documented escape hatch, because dogma loses to a typo fix. The two packages version and release independently.
// migrations
A re-platform done in the open
The schema was rebuilt once already. 26 live migrations, a written cutover runbook, an ETL script, and a reconcile script whose only job is to prove the old and new datasets agree. The superseded migrations are archived rather than deleted, so the history still explains itself.
// the contract
43 primitives and a written rule
A design system with a living specimen page, governed by an explicit use / extend / deviate rule — and an audit that logs every place the app deviates anyway. A design system nobody can cheat quietly is the only kind that survives contact with a deadline.

/// decisions I'd defend

Poll, don't webhook
Inbound payment confirmation is polled because the bank will never call us; outbound messaging is pushed. Choosing the right direction per integration beats forcing one pattern on both.
No realtime layer
No sockets. Liveness is server-rendered revalidation and an explicit refresh. A volleyball roster changes a few times an hour, not a few times a second — a persistent connection per viewer would have been infrastructure bought to solve a problem nobody had.
Installable, deliberately not offline
The app installs to a phone home screen, and its service worker says in a comment that it caches nothing on purpose. An app whose entire content is who else is playing tonight has nothing honest to show you offline.
One shell, four roles
Player, host, cohost and admin are separate passwordless sessions that can be held at the same time, so testing a host flow doesn't cost you your player session. Host access is approval-gated rather than self-serve — publishing a game takes on other people's money and evenings.
The scoreboard is a route, not a modal
Live scoring opens over the league page as an intercepting parallel route: a real URL you can send to the person holding the clipboard, that still renders as a modal over the standings when you click into it from there.

/// stack

// web
  • Next.js 16 · App Router
  • React 19 · server components
  • TypeScript
  • Tailwind CSS v4
  • Zod at every boundary
  • Vitest
// api
  • ASP.NET Core · .NET 10
  • EF Core · Npgsql
  • Vertical slice features
  • Transactional outbox
  • xUnit · Testcontainers
// infra
  • Postgres 16
  • Docker Compose · Coolify
  • Vercel · cron + blob storage
  • Gmail API · read-only
  • Resend · transactional email
  • Self-hosted WhatsApp HTTP API
next_steps.md
currently taking on new projects

Your problem is weirder than a payment form.
Good. Those are the fun ones.

// back to the index