Futbol Veritabanı
8대 리그 클럽·선수·통계—Transfermarkt 데이터
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A membership, email, and invoicing system for a hockey asociation
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A comprehensive membership and registration system for adult hockey associations, built with Next.js, Supabase, and Stripe.
• ✅ Feature branches → development: Squash merge
• ✅ Development → main: Regular mergeREADME 전문 보기 ↗A comprehensive membership and registration system for adult hockey associations, built with Next.js, Supabase, and Stripe.
Every promotion PR (development → main) will show GitHub's "This branch is out-of-date with the base branch" banner, even when it's the very first check after opening the PR and even with only one merge into development since the last promotion. This is expected, not a sign of missing work: each promotion creates a merge commit that lives only on main (it's never merged back into development, since promotion is one-directional), so main's tip is immediately a commit development doesn't have in its ancestry — regardless of how many feature merges land in development afterward. It recurs on every single promotion. Content-wise the branches stay identical (verify with git diff main development, ignoring the promotion PR's own new files, if in doubt); the banner is purely an artifact of merge-commit ancestry. Click "Merge pull request" directly — clicking "Update branch" instead would merge main into development first, which isn't part of this workflow and adds a pointless extra merge commit, since there's nothing for it to actually bring in.
The system uses a sophisticated line item categorization system to track financial data for accurate reporting. All financial transactions are stored in the xero_invoice_line_items table with specific line_item_type values that determine how they appear in financial reports.
reports_financial_dataThe reports_financial_data view is the primary source for financial reporting. It joins xero_invoice_line_items with related tables and includes only AUTHORISED invoices (not DRAFT) with completed payments.
The xero_invoice_line_items.line_item_type field categorizes financial data into four main types:
line_item_type = 'membership')line_item_type = 'registration')line_item_type = 'discount')discount_code_id populatedline_item_type = 'donation')Based on these line item types, the financial reports are organized as:
membership line items, grouped by membership typediscount line items with discount_code_id, grouped by discount categorydonation line items, separated into received (positive) and given (negative)registration line items, grouped by registration typediscount line items with proper discount_code_iddonation line items (negative amount)users table via the view.nvmrc, and package.json engines and CI use the same version. With nvm, run nvm install once, then nvm use in the repo.git clone <repository-url>
cd membership-system
nvm use
npm install
Copy .env.example to .env.local and fill in real values (Google OAuth is configured in the Supabase dashboard, not via app env vars):
# Database
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
SUPABASE_MAX_ROWS=1000
# Payments
STRIPE_SECRET_KEY=rk_test_your_stripe_restricted_key # restricted key, see "Stripe restricted API key"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
STRIPE_API_VERSION=2026-09-30.endive
# Email Integration
LOOPS_API_KEY=your_loops_api_key
LOOPS_EMAIL_BATCH_DELAY_MS=1000
LOOPS_WELCOME_TEMPLATE_ID=your_welcome_template_id
LOOPS_MEMBERSHIP_PURCHASE_TEMPLATE_ID=your_template_id
LOOPS_MEMBERSHIP_EXPIRING_TEMPLATE_ID=your_membership_expiring_template_id
LOOPS_PAYMENT_FAILED_TEMPLATE_ID=your_payment_failed_template_id
LOOPS_PAYMENT_METHOD_REMOVED_TEMPLATE_ID=your_payment_method_removed_template_id
LOOPS_PAYMENT_PLAN_PRE_NOTIFICATION_TEMPLATE_ID=your_payment_plan_pre_notification_template_id
LOOPS_PAYMENT_PLAN_PAYMENT_PROCESSED_TEMPLATE_ID=your_payment_plan_payment_processed_template_id
LOOPS_PAYMENT_PLAN_PAYMENT_FAILED_TEMPLATE_ID=your_payment_plan_payment_failed_template_id
LOOPS_PAYMENT_PLAN_COMPLETED_TEMPLATE_ID=your_payment_plan_completed_template_id
LOOPS_REGISTRATION_CONFIRMATION_TEMPLATE_ID=your_registration_template_id
LOOPS_WAITLIST_ADDED_TEMPLATE_ID=your_waitlist_template_id
LOOPS_WAITLIST_SELECTED_TEMPLATE_ID=your_waitlist_selected_template_id
LOOPS_ALTERNATE_SELECTION_TEMPLATE_ID=your_alternate_selection_template_id
LOOPS_ALTERNATE_REGISTRATION_CONFIRMATION_TEMPLATE_ID=your_alternate_registration_confirmation_template_id
LOOPS_EMAIL_CHANGE_CONFIRMED_TEMPLATE_ID=your_email_change_confirmed_template_id
LOOPS_REFUND_TEMPLATE_ID=your_refund_template_id
LOOPS_TEAM_MESSAGE_TEMPLATE_ID=your_team_message_template_id
LOOPS_CAPTAIN_ASSIGNMENT_NOTIFICATION_TEMPLATE_ID=your_captain_assignment_template_id
LOOPS_CAPTAIN_REMOVAL_NOTIFICATION_TEMPLATE_ID=your_captain_removal_template_id
LOOPS_CAPTAIN_ROSTER_CHANGE_TEMPLATE_ID=your_captain_roster_change_template_id
LOOPS_ADMIN_NEW_REGISTRATION_TEMPLATE_ID=your_admin_new_registration_template_id
LOOPS_ADMIN_REFUND_TEMPLATE_ID=your_admin_refund_template_id
LOOPS_ADMIN_PAYMENT_PLAN_FAILURE_TEMPLATE_ID=your_admin_payment_plan_failure_template_id
# Error Monitoring
NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn_here
SENTRY_ORG=your_sentry_org
SENTRY_PROJECT=membership-system
# Auth token for uploading source maps at build time (readable stack traces in Sentry).
# Requires "Source Map Upload" + "Release Creation" scopes. Not needed for local dev.
SENTRY_AUTH_TOKEN=your_sentry_auth_token_here
# Xero Integration Configuration
# Recommendation: Create separate Xero apps for development/preview/production environments
XERO_CLIENT_ID=your_xero_client_id_here
XERO_CLIENT_SECRET=your_xero_client_secret_here
XERO_REDIRECT_URI=http://localhost:3000/api/xero/callback
XERO_SCOPES=accounting.transactions accounting.contacts accounting.settings offline_access
# Cron
CRON_SECRET=your_random_cron_secret_for_cron_jobs
# Application Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
ORGANIZATION_NAME=Your Organization Name
[email protected]
# Timezone Configuration
# IANA timezone identifier for date/time display throughout the application
# Examples: America/New_York, America/Los_Angeles, Europe/London, UTC
NEXT_PUBLIC_APP_TIMEZONE=America/New_York
Automated preview testing (e.g. an AI agent driving a browser against a preview URL) needs a few additional Preview-scoped-only vars — see Preview auth bypass for automated testing.
Create a new Supabase project
Get your API keys from Supabase Dashboard:
NEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_SUPABASE_URLNote: Use the new API keys (not the legacy ones). The new keys use an improved JWT format and are the recommended approach.
Run the database schema:
Open supabase/schema.sql, paste it into your Supabase SQL editor, and run it.
That single file creates every table, constraint, index, function, trigger,
view, RLS policy and comment the app expects — RLS included, so there is no
separate policy step.
schema.sql is generated from supabase/migrations/ (npm run schema:build),
so it can't fall behind the migrations the way it did before. Migrations use
the Supabase CLI's <timestamp>_name.sql naming, so supabase db push also
works against an existing project.
The login screen offers two ways in — a link or a 6-digit code — but src/app/auth/login/page.tsx makes a single signInWithOtp() call, so one email has to carry both. The choice on screen changes the wording, not the email. That template needs:
{{ .Token }}, the code typed into /auth/verify-otp/auth/magic-confirm?token_hash={{ .TokenHash }}&type=magiclink, which src/app/auth/magic-confirm/page.tsx verifies with supabase.auth.verifyOtp()Two spellings of that link both work, and projects here use both. {{ .SiteURL }}/auth/magic-confirm?token_hash=... always lands on the site, whichever deployment asked for the email. {{ .RedirectTo }}?token_hash=... honours the emailRedirectTo the login page passes, so a magic link requested from a preview deployment comes back to that preview — but only while the preview URL is in the project's Redirect URLs allowlist; when it isn't, Supabase falls back to the Site URL and drops the /auth/magic-confirm path with it, leaving a link to the site root that verifies nothing. auth:check accepts either form.
A fresh project's default Magic Link template has neither: it links to {{ .ConfirmationURL }}, a PKCE-flow link (?code=...) that magic-confirm cannot verify, so every magic-link sign-in lands on /auth/auth-code-error. A template with only one of the two half-works, which is harder to spot — whichever method the user picks, the email may not contain what it needs.
The template lives at supabase/auth-templates/magic-link.html and is applied through Supabase's Management API:
export SUPABASE_ACCESS_TOKEN=sbp_... # https://supabase.com/dashboard/account/tokens
npm run auth:apply -- --dry-run # show what would change
npm run auth:apply # write it to the project
npm run auth:check # confirm the live project matches
auth:check prints the project's current template when it fails, so you can see what is actually there without opening the dashboard. The project is taken from NEXT_PUBLIC_SUPABASE_URL in your .env.local, so this targets whichever project the rest of your setup points at; pass --project-ref <ref> to override. auth:apply refuses to overwrite a template that already works — a branded one somebody wrote by hand — unless you pass --force, and it prints what it replaced either way. Only the magic-link body is touched; the subject line and every other auth template are left alone.
To do it by hand instead, go to Authentication → Email Templates → Magic Link in the dashboard and point the link at:
{{ .SiteURL }}/auth/magic-confirm?token_hash={{ .TokenHash }}&type=magiclink
This is dashboard-only state with no trace in schema.sql or the migrations, so it has to be set on every project — production, development, and any personal sandbox. npm run auth:check is how you find out whether a given project has it; run it against a project whose magic links have stopped working before assuming the bug is in the app. It applies to any custom template you write later too; see Custom SMTP Configuration.
npm run dev
Open http://localhost:3000 to see the application.
supabase/migrations/ holds the schema, as timestamped files the Supabase CLI understands (YYYYMMDDHHMMSS_name.sql, UTC). History before 2026-09-07 was collapsed into 20260907000000_baseline_schema.sql, a snapshot of the production database; the 140 dated files it replaced are in git history.
supabase/schema.sql is generated from that directory — never edit it directly:
npm run schema:build # regenerate after adding a migration; commit the result
npm run schema:check # fails if schema.sql is stale (blocking in CI)
npm run migrations:lint # rejects unsafe views and public EXECUTE grants (blocking in CI)
npm run schema:verify # applies schema.sql twice to a throwaway database
schema:verify needs a reachable PostgreSQL server via the standard PG* variables; CI runs it against a service container. It exists because parsing SQL is not enough to catch a broken migration — COMMENT ON TABLE <a view> parses cleanly and fails on execution.
Migrations are applied deliberately, not as a side effect of merging. Two supported routes:
Through the workflow — the supported route. It runs supabase db push, which records what it applied in supabase_migrations.schema_migrations. Everything downstream depends on that record being true: db push skips what's already applied, and Migration applied to target database (below) compares against it.
By hand, in the SQL editor — only as a fallback, and never on its own. The editor runs the SQL but writes no tracking row, so the CLI still believes the migration is pending: the next db push tries to re-apply it and the preflight check reports the target as behind even though the change is live. If you do apply by hand, immediately record it:
supabase migration repair --status applied <version> --db-url "postgresql://..."
This is not theoretical — it's why the pre-2026-09-07 history was invisible to the CLI, and why seeding was needed before any of this could work.
The development database is the exception to "deliberately": preview deployments run against it, so a migration is normally applied there during feature work, before the branch is pushed — see AGENTS.md. Production is meant to be applied by a human before the PR that adds it merges — and Migration applied to target database (below) is a required PR check that enforces exactly that, rather than leaving it to convention.
To run it: Actions → Apply database migrations → Run workflow, pick development or production, and leave dry run checked for the first pass. The dry run prints supabase migration list, showing which files the target database has and hasn't seen. Re-run with dry run unchecked to apply.
It also runs on merge. A push to main or development that changes anything under supabase/migrations/ starts the workflow, and applies immediately and unattended on both branches — neither Environment has a required reviewer (see "Setting up the workflow" below for why, and what replaced it). Pushes that don't touch migrations trigger nothing. On development unattended is the point — previews run against that database. On main it lands in the same moment as Vercel's production deploy, but should be a no-op by the time it runs: the preflight check below already forced a manual apply before the merge was even allowed.
What a failure means. This workflow runs after the push has landed, so it cannot fail the merge or undo it. A red run means the commit is on the branch and the migration is not applied — the code is ahead of the database, and nothing was rolled back. Recovery is to fix the migration in a follow-up and let it re-run, or apply by hand and repair the history.
Two different questions get answered before merge, by two different checks. Whether a migration is safe is schema:check, migrations:lint, and the schema job, which applies schema.sql to a real PostgreSQL container — those catch a migration that shouldn't exist. Whether it's applied yet is Migration applied to target database, below — a required check that fails the pull request outright if its target database is missing anything the repo carries, catching a migration that's perfectly safe but that nobody has run. The apply workflow itself is downstream of both: by the time a merge triggers it, everything should already be in place.
What neither PR check can see is SQL applied by hand outside the repo entirely — an object created in the SQL editor writes no row to supabase_migrations.schema_migrations, so there's nothing here to compare against. That's a separate, harder problem, tracked in #312.
Migration applied to target database (db-migration-preflight.yml) compares supabase/migrations/ against the target database's supabase_migrations.schema_migrations, as a required PR check — on every pull request into main or development, not just ones that touch supabase/migrations/ (a required check has to report on every PR, or it blocks merges forever waiting for a run that never comes — see "Two things about the status checks specifically"). It fails the PR if the branch it's merging into is missing a migration this repo carries, with a message telling you to run Apply database migrations first.
This is what makes "apply before merge" a rule instead of a habit: nothing merges until the target database is caught up, and you control exactly when you run the apply workflow rather than waiting on anyone's approval.
It needs read access to the target database: SUPABASE_DB_URL for supabase-development (already configured, ungated), and SUPABASE_DB_URL_READONLY for the ungated supabase-production-readonly Environment described below — never the gated admin credential, or this check would inherit the same "waits for approval" problem it exists to route around.
Those checks only block a merge if you require them. Without a ruleset, a red pull request can still be merged and anyone can push straight to main, skipping the checks entirely — "we always use a PR" is a convention rather than a guarantee.
Settings → Rules → Rulesets → New branch ruleset. One ruleset covers both branches: under Target branches → Include → add main and development. Set Enforcement status: Active — a ruleset can be saved in Disabled or Evaluate mode, where it reports without blocking anything. Leave the bypass list empty; adding "Repository admin" makes the whole thing advisory for exactly the person doing the merging.
Rules to enable:
| Rule | Setting |
|---|---|
| Require a pull request before merging | On, with required approvals: 0 |
| Require status checks to pass | On — add test, schema, and Migration applied to target database, and tick Require branches to be up to date before merging |
| Restrict deletions | On (default) |
| Block force pushes | On (default) |
Required approvals defaults to 1, and nobody can approve their own pull request — on a repository with effectively one maintainer that setting locks you out of merging your own work. Zero still gives you the pull request and the checks.
Rules to leave off:
development → main is a regular merge (see Git Merge Strategies).Two things about the status checks specifically:
schema is newer than test, so it may not be listed until a pull request has exercised it. The same applies to Migration applied to target database, which is newer still. Add each afterwards rather than typing the name by hand — a ruleset treats an unrecognised check as "never reported" and blocks every merge until something reports it.Migration applied to target database, but not Apply database migrations. The former runs on pull_request and is what actually blocks a merge ahead of an unapplied migration (see "Enforcing it before merge" above). The latter never runs on pull requests at all, so requiring it would deadlock every merge waiting for a check that can't report.One consequence to expect: with a pull request required on main, promoting development → main becomes a pull request rather than a local merge and push. Same result, one extra step, and the checks run against the exact commit that lands.
This is the route for contributors who don't hold database credentials: the connection string lives in the GitHub Environment, so anyone with write access to the repository can apply to development from the Actions tab without having it locally. Pull requests from forks can't reach repository secrets at all — ask a maintainer to run it.
One-time, per project:
Seed the CLI's migration history. Already done for the existing dev and production projects on 2026-09-07 — all three of 20260907000000, 20260907000001 and 20260907000002 are recorded as applied on both. This step only applies to a new project (a personal sandbox, say).
Migrations applied by hand are invisible to the CLI: it tracks what it has run in supabase_migrations.schema_migrations, and a manual run leaves no row there. Without seeding it, the first db push tries to re-apply everything to a database that already has it:
supabase migration repair --status applied 20260907000000 20260907000001 20260907000002
Run it against each project, naming whichever migrations that database has already received. Verify with supabase migration list.
Create GitHub Environments named supabase-development and supabase-production (Settings → Environments). Neither has a required reviewer.
Deliberately not the existing Production / Preview environments: those belong to the Vercel integration, and reusing them would tangle Vercel deployments with migrations, and the names are ambiguous besides — Preview is a Vercel deployment target, not a database. Leave Vercel's alone.
A required reviewer on supabase-production used to be the approval gate here, but it ran after the push had already landed on main — by which point Vercel had already deployed the new application code, independent of this workflow. That left a real window (observed once at over five hours) where production ran new code against old schema before anyone approved the migration. The gate moved earlier instead, to the pull request itself: see "Enforcing it before merge" above, and "CODEOWNERS" just below for a complementary, non-blocking signal.
.github/CODEOWNERS names a reviewer for supabase/migrations/, so a promotion PR (development → main) carrying a migration surfaces that reviewer in its Files Changed / reviewers panel — a useful pointer to whose eyes belong on the diff, on top of the required check above, but not itself a GitHub-enforced block. Branch protection doesn't require Code Owner review here, and it couldn't easily be turned into one on this repository: GitHub never lets a PR author satisfy a required review on their own PR, including a Code Owner review, so requiring it would deadlock every promotion this single-maintainer account opens. Migration applied to target database is what actually blocks the merge; CODEOWNERS is just where you'd look to see who should be looking.
The push: trigger in .github/workflows/db-migrate.yml is already live for both development and main — nothing to uncomment. Both apply unattended; see "Enforcing it before merge" above for where the human decision actually happens for production.
Migration applied to target database (above) must not use the admin SUPABASE_DB_URL for production: that secret lives in the supabase-production Environment, which is where a required reviewer would go if you turn one on (see "Setting up the workflow" above) — and this check has to run unattended on every single PR to do its job. Point it at a gated credential and either it inherits the gate (every PR now waits on an approval for a read-only SELECT, reintroducing the exact silent-waiting problem #290 was about) or you leave supabase-production permanently ungated just to keep the check working, which throws away the point of gating the admin credential at all.
The fix is a dedicated low-privilege role for production, not a copy of the admin connection string into an ungated Environment:
Apply the ci_readonly_role migration to each project (it's in supabase/migrations/, like any other). It creates a ci_readonly role that can SELECT supabase_migrations.schema_migrations and nothing else — no anon/authenticated membership, no BYPASSRLS.
Set its password out-of-band, once per project, e.g. from the SQL editor or psql:
ALTER ROLE ci_readonly WITH PASSWORD '<generate one — never reuse the admin password>';
Never put this in a migration file — it would land in git and in schema.sql.
Create an ungated supabase-production-readonly GitHub Environment (Settings → Environments) — no required reviewer, since there's nothing for one to approve. Add a SUPABASE_DB_URL_READONLY secret to it, built the same way as SUPABASE_DB_URL above (Session pooler, port 5432) but with ci_readonly.<project-ref> as the username instead of postgres.<project-ref> and that role's password.
Verify the pooler accepts that username on the development project before relying on it — Supavisor's tenant-user format is normally <role>.<project-ref>, but this repo hadn't previously used any role but postgres through it, so treat it as unconfirmed until you've connected with it once:
psql "postgresql://ci_readonly.qojixnzpfkpteakltdoa:<password>@aws-1-us-east-2.pooler.supabase.com:5432/postgres" \
-tAc "select version from supabase_migrations.schema_migrations order by version"
supabase-development needs no equivalent role or Environment. It's not that development is inherently safe to expose — it's that its admin credential is already reachable by an unattended, automatic workflow: db-migrate.yml applies to it on every push, no gate, no approval, by design (previews need it current). The preflight check reading through that same already-unattended credential doesn't cross any new line. Production's admin credential is deliberately kept away from anything unattended — that's what the reviewer gate on db-migrate.yml is for — so handing it to a check that has to run unattended on every PR would be a new, meaningfully larger exposure, not a continuation of an existing one. ci_readonly is what lets the preflight check run unattended on production without making that trade.
Preview deploys point at the shared development Supabase project, so a migration applied there is visible to every open preview at once, and to whatever is already merged. Write migrations expand/contract so that's safe:
NOT NULL without a backfill in the same file.development.The alternative — a real database per pull request — is Supabase's branching feature. It isn't enabled on this project; it costs per branch and needs the Vercel integration wired to point previews at the branch database. Worth revisiting if two people start colliding on the shared dev database.
Someone will eventually want member data in a spreadsheet, a dashboard, or another tool. There is one supported way to do that, and one way that looks easier but publishes your members' data to the internet.
Never create a view or a database function to serve an external consumer. Build an authenticated API route instead.
Supabase publishes everything in the public schema through PostgREST. The moment a view or function exists there, it has a public URL — /rest/v1/<view> or /rest/v1/rpc/<function> — and the anon key that unlocks it is not a secret: it ships in the browser bundle of every page on the site. Two specific traps:
security_invoker=true runs as its owner and ignores the RLS on the tables underneath it, while Supabase's default privileges hand anon SELECT on it automatically.SECURITY DEFINER function ignores RLS entirely, and (before 20260907000002_revoke_public_function_access.sql) new functions were granted to anon and authenticated by default.This is not hypothetical. Three export helpers — get_users_data(), get_full_data() and get_current_data() — were created directly in the SQL editor to feed a Google Sheet, and explicitly granted to anon. For about eleven weeks, anyone on the internet could POST /rest/v1/rpc/get_users_data and receive every member's first name, last name, email address and member ID. They were dropped in that migration and replaced by the route described below.
src/app/api/admin/exports/members/route.ts is the reference implementation. The shape of it:
import { NextRequest, NextResponse } from 'next/server'
import { timingSafeEqual } from 'crypto'
import { createAdminClient } from '@/lib/supabase/admin'
import { logger } from '@/lib/logging/logger'
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided)
const b = Buffer.from(expected)
if (a.length !== b.length) return false // timingSafeEqual throws on length mismatch
return timingSafeEqual(a, b) // constant time: no character-by-character leak
}
export async function GET(request: NextRequest) {
const expected = process.env.EXPORT_API_SECRET
// Fail closed. A missing secret must never mean "no authentication required".
if (!expected) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 })
}
const header = request.headers.get('authorization') ?? ''
const token = header.startsWith('Bearer ') ? header.slice(7) : ''
if (!token || !secretMatches(token, expected)) {
logger.logAdminAction('export-unauthorized', 'Rejected request', {}, undefined, 'warn')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only past the gate do we reach for the service-role client.
const supabase = createAdminClient()
const { data, error } = await supabase.from('users').select('first_name, last_name, email, member_id')
if (error) {
return NextResponse.json({ error: 'Export failed' }, { status: 500 }) // don't echo the DB error
}
return NextResponse.json({ rows: data }, { headers: { 'Cache-Control': 'no-store' } })
}
Why each piece matters:
| Practice | Reason |
|---|---|
| Fail closed when the secret is unset | A deploy that forgets the env var must break loudly, not serve the data to everyone. The /api/cron/* routes used to get this wrong in two different ways — see src/lib/cron/auth.ts, the shared guard all six now call, for the fixed version. |
timingSafeEqual, not === | String comparison short-circuits on the first wrong byte; response timing then leaks the secret one character at a time. |
| Service-role client after the auth check | The export is deliberately cross-member, which every RLS policy correctly forbids. The route's own auth is what replaces RLS, so it has to come first. |
| Never log the token | A rejected guess in your logs is still a credential. Log that a header was present, not what it said. |
| Generic error bodies | relation "users" does not exist tells an attacker about your schema. |
Cache-Control: no-store | Keeps member PII out of intermediary caches. |
| Paginate | PostgREST caps a response at 1000 rows; without .range() looping you silently export a truncated roster. |
openssl rand -base64 32
EXPORT_API_SECRET.
.env.local (never committed). vercel env pull .env.local brings the Development value down.Never hand out the SUPABASE_SERVICE_ROLE_KEY instead. It bypasses RLS on every table in the database; the whole point of a scoped endpoint is that a leaked export credential exposes one dataset, not everything.
Test with curl first:
curl -H "Authorization: Bearer $EXPORT_API_SECRET" \
"https://your-domain.com/api/admin/exports/members?dataset=members"
For a Google Sheet, use Apps Script (Extensions → Apps Script). Store the secret in Script Properties — Project Settings → Script properties — rather than pasting it into the code, so it isn't visible to anyone with view access to the script:
function refreshMembers() {
const secret = PropertiesService.getScriptProperties().getProperty('EXPORT_API_SECRET');
const url = 'https://your-domain.com/api/admin/exports/members?dataset=members';
const response = UrlFetchApp.fetch(url, {
headers: { Authorization: 'Bearer ' + secret },
muteHttpExceptions: true,
});
if (response.getResponseCode() !== 200) {
throw new Error('Export failed: ' + response.getResponseCode() + ' ' + response.getContentText());
}
const rows = JSON.parse(response.getContentText()).rows;
const header = ['first_name', 'last_name', 'email', 'member_id'];
const values = [header].concat(rows.map(function (r) {
return header.map(function (key) { return r[key]; });
}));
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Members');
sheet.clearContents();
sheet.getRange(1, 1, values.length, header.length).setValues(values);
}
Then Triggers → Add Trigger → refreshMembers, time-driven, daily.
IMPORTDATA and IMPORTJSON can't be used: neither can send an Authorization header, which is exactly why the previous approach reached for a public endpoint. That constraint is the point — if a formula can read it without credentials, so can the internet.
GET /api/admin/exports/members
| Parameter | Values | Meaning |
|---|---|---|
dataset | members (default) | One row per member: name, email, member ID |
memberships | One row per member per membership type, with the latest expiry | |
membership_id | uuid | Restrict memberships to one membership type |
paid_only | true | Count only paid memberships (default: all, including pending and refunded) |
include_deleted | true | Include soft-deleted members (default: excluded) |
format | json (default), csv | Response format |
npm run migrations:lint (blocking in CI) rejects views created without security_invoker=true and GRANT EXECUTE to public roles, so the database-side mistake fails the build.get_advisors over MCP) flags anon-executable SECURITY DEFINER functions and views that bypass RLS. Check it after any schema change.The system uses Loops.so for transactional email delivery. Follow these steps to configure email templates:
Important: Every transactional email template's Subject field must start with the
{testEmailPrefix}merge tag, e.g.{testEmailPrefix}Welcome to the Hockey Association!. The app sends atestEmailPrefixdata variable on every send —[TEST]on preview/local deployments, an empty string in production — so preview and production emails stay visually distinguishable even though they share the same templates. MarktestEmailPrefixas optional when adding the data variable in Loops (this is a safety net only: the app always sends the variable, including an empty string in production, so the template renders correctly whether or not it's marked optional).
LOOPS_API_KEY to your environment variablesIn Loops Dashboard:
Add Data Variables: Use the "Insert data variable" button to add these variables:
userName - Customer's full namemembershipName - Type of membership purchasedamount - Price (formatted as "45.00")durationMonths - Duration in monthsvalidFrom - Membership start datevalidUntil - Membership end datepurchaseDate - Purchase datepaymentIntentId - Stripe transaction IDdashboardUrl - Link to user dashboardEmail Template Example:
Hi [userName],
Great news! Your membership purchase has been confirmed and your access is now active.
MEMBERSHIP DETAILS:
- Membership Type: [membershipName]
- Duration: [durationMonths] months
- Amount Paid: $[amount]
- Valid From: [validFrom]
- Valid Until: [validUntil]
- Purchase Date: [purchaseDate]
WHAT'S NEXT:
• Browse available teams and events you can now join
• Manage your account: [dashboardUrl]
• Watch for updates about league schedules
Transaction ID: [paymentIntentId]
Questions? Reply to this email or contact support.
Thank you for being part of our hockey community!
The Hockey Association Team
Get Template ID:
.env.local as LOOPS_MEMBERSHIP_PURCHASE_TEMPLATE_IDThe system includes several other email types that need templates configured:
LOOPS_WELCOME_TEMPLATE_ID)Data Variables:
userName - New user's full namedashboardUrl - Link to user dashboardmembershipUrl - Link to membership purchase pageTemplate Example:
Hi [userName],
Welcome to the Hockey Association! 🏒
Your account has been successfully created. Here's what you can do next:
• Explore membership options: [membershipUrl]
• Access your dashboard: [dashboardUrl]
• Browse upcoming seasons and events
We're excited to have you as part of our hockey community!
Questions? Reply to this email anytime.
The Hockey Association Team
LOOPS_MEMBERSHIP_EXPIRING_TEMPLATE_ID)Sent by the /api/cron/membership-reminders job (see § Cron Jobs) when a membership's valid_until is exactly 30, 14, 7, or 1 days away.
Data Variables:
user_name - Member's full namemembership_name - Type of membership expiringexpiration_date - When membership expiresdays_until_expiration - Number of days remaining (30, 14, 7, or 1)renew_url - Link to renewal pageTemplate Example:
Hi [user_name],
Your [membership_name] will expire in [days_until_expiration] days on [expiration_date].
To avoid any interruption to your membership benefits, please renew before the expiration date.
Renew now: [renew_url]
Questions about renewal? Reply to this email.
The Hockey Association Team
LOOPS_PAYMENT_FAILED_TEMPLATE_ID)Data Variables:
userName - Customer's full namemembershipName - Type of membership attemptedamount - Payment amount that failedfailureReason - Reason for payment failureretryUrl - Link to retry paymentTemplate Example:
Hi [userName],
We were unable to process your payment for [membershipName] in the amount of $[amount].
Reason: [failureReason]
Please try again: [retryUrl]
If you continue to have issues, please contact our support team.
The Hockey Association Team
LOOPS_REGISTRATION_CONFIRMATION_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of event/team registered forcategoryName - Registration category (Player, Goalie, etc.)seasonName - Season name and datesamount - Registration fee paid (formatted as "75.00")registrationDate - Date of registrationpaymentIntentId - Stripe transaction IDdashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
Congratulations! Your registration has been confirmed and payment processed successfully.
REGISTRATION DETAILS:
- Event/Team: [registrationName]
- Category: [categoryName]
- Season: [seasonName]
- Amount Paid: $[amount]
- Registration Date: [registrationDate]
WHAT'S NEXT:
• Watch for team communications and schedule updates
• Manage your registrations: [dashboardUrl]
• Contact team organizers if you have questions
Transaction ID: [paymentIntentId]
Questions about your registration? Reply to this email.
Welcome to the team!
The Hockey Association Team
LOOPS_WAITLIST_ADDED_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of event/team they were waitlisted forcategoryName - Registration category (Player, Goalie, etc.)seasonName - Season name and datesposition - Position on the waitlistwaitlistDate - Date added to waitlistdashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
You've been added to the waitlist for [registrationName].
WAITLIST DETAILS:
- Event/Team: [registrationName]
- Category: [categoryName]
- Season: [seasonName]
- Your Position: #[position] on the waitlist
- Added to Waitlist: [waitlistDate]
WHAT'S NEXT:
• You'll be notified if a spot becomes available
• Check your status anytime: [dashboardUrl]
• We'll contact you with further instructions if selected
Thank you for your interest in joining the team!
The Hockey Association Team
LOOPS_WAITLIST_SELECTED_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of event/teamcategoryName - Registration categoryseasonName - Season name and datesamount - Amount charged (formatted as number, e.g., "50.00")purchaseDate - Date of charge (formatted as localized date string)paymentIntentId - Stripe payment intent ID (or "N/A" for free charges)discountApplied - Optional discount details (e.g., "CODE25: -$25.00")dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
Great news! You've been selected from the waitlist for [registrationName].
REGISTRATION CONFIRMATION:
- Event/Team: [registrationName]
- Category: [categoryName]
- Season: [seasonName]
- Amount Charged: $[amount]
- Payment Date: [purchaseDate]
- Payment Intent ID: [paymentIntentId]
[if discountApplied]
- Discount Applied: [discountApplied]
[endif]
WHAT'S NEXT:
• Your payment method has been charged
• You're now fully registered
• View your registration: [dashboardUrl]
Questions? Reply to this email anytime.
Best,
The Hockey Association Team
LOOPS_ALTERNATE_SELECTION_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of registration (e.g., "Rec League - Fall/Winter 2025-26")seasonName - Season namegameDescription - Description of the specific game (e.g., "Game 1")gameDate - Formatted game date (e.g., "Monday, September 11, 2025")gameTime - Formatted game time (e.g., "7:00 PM")amount - Amount charged as formatted number (e.g., 22.50)purchaseDate - Formatted purchase date (e.g., "September 11, 2025")dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
You've been confirmed as an alternate for [gameDescription]!
GAME DETAILS:
- Registration: [registrationName] ([seasonName])
- Game: [gameDescription]
- Date & Time: [gameDate] at [gameTime]
- Amount Paid: $[amount]
- Confirmation Date: [purchaseDate]
WHAT'S NEXT:
• You're now on the alternate list for this game
• You'll be contacted if a spot becomes available
• Check your status anytime: [dashboardUrl]
Thanks for being part of the team!
The Hockey Association Team
LOOPS_ALTERNATE_REGISTRATION_CONFIRMATION_TEMPLATE_ID)Sent to the member when they sign up as an alternate for a registration (event type alternate.registered). Always sent (not gated on email preferences). If the template ID is not configured, the send is skipped with a warning and the sign-up still succeeds.
Data Variables:
userName - Member's full nameregistrationName - Name of registration (e.g., "NYCPHA Recreational League")seasonName - Season name (e.g., "Fall/Winter 2026")categoryName - Always "Alternate"registrationDate - Sign-up date in Eastern Time, same format as the registration confirmation (e.g., "9/19/2026")alternatePrice - Per-game alternate price before any discounts, as a formatted number (e.g., "25.00"); charged only if selected for a gamedashboardUrl - Link to user dashboard (/user)Template Example:
Hi [userName],
You're signed up as an alternate for [registrationName] ([seasonName]).
ALTERNATE DETAILS:
- Registration: [registrationName]
- Season: [seasonName]
- Role: [categoryName]
- Signed Up: [registrationDate]
- Price per Game (before any discounts, charged only if selected): $[alternatePrice]
WHAT BEING AN ALTERNATE MEANS:
• Captains or admins may select you to fill in for specific games
• You're only charged (using your saved payment method) when you're selected for a game
• You'll get a separate confirmation each time you're selected
CHANGE OR WITHDRAW:
• To withdraw or change your alternate sign-up, reply to this email or contact the league admins
• View your registrations anytime: [dashboardUrl]
Thanks for being part of the team!
The Hockey Association Team
LOOPS_PAYMENT_METHOD_REMOVED_TEMPLATE_ID)Data Variables:
userName - Customer's full namepaymentMethod - Last 4 digits of removed payment method (e.g., "****1234")Template Example:
Hi [userName],
Your payment method [paymentMethod] has been successfully removed from your account.
WHAT THIS MEANS:
• You've been removed from all alternate registrations
• You'll need to add a new payment method to register as an alternate again
• Your existing team registrations are not affected
If you didn't request this change or have questions, please contact our support team immediately.
The Hockey Association Team
LOOPS_PAYMENT_PLAN_PRE_NOTIFICATION_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of registrationseasonName - Season name and datescategoryName - Registration categorynextPaymentDate - Date of upcoming payment (formatted as localized date string)nextPaymentAmount - Amount to be charged (formatted as "25.00")installmentNumber - Which installment (2, 3, or 4)remainingBalance - Total remaining balance (formatted as "75.00")dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
This is a friendly reminder that your next payment plan installment is coming up soon.
UPCOMING PAYMENT:
- Registration: [registrationName] ([seasonName])
- Category: [categoryName]
- Payment [installmentNumber] of 4
- Amount: $[nextPaymentAmount]
- Scheduled Date: [nextPaymentDate]
PAYMENT PLAN STATUS:
- Remaining Balance: $[remainingBalance]
- Payment Method: Your saved payment method will be charged automatically
WHAT YOU CAN DO:
• View payment plan details: [dashboardUrl]
• Pay remaining balance early (no penalty)
• Update your payment method if needed
Questions? Reply to this email anytime.
The Hockey Association Team
LOOPS_PAYMENT_PLAN_PAYMENT_PROCESSED_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of registrationseasonName - Season name and datescategoryName - Registration categorypaymentDate - Date payment was processed (formatted as localized date string)paymentAmount - Amount charged (formatted as "25.00")installmentNumber - Which installment was paid (2, 3, or 4)totalPaid - Total amount paid so far (formatted as "50.00")remainingBalance - Remaining balance (formatted as "50.00")nextPaymentDate - Next payment date OR "No more payments due"dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
Your payment plan installment has been successfully processed!
PAYMENT DETAILS:
- Registration: [registrationName] ([seasonName])
- Category: [categoryName]
- Payment [installmentNumber] of 4
- Amount Charged: $[paymentAmount]
- Payment Date: [paymentDate]
PAYMENT PLAN STATUS:
- Total Paid: $[totalPaid]
- Remaining Balance: $[remainingBalance]
- Next Payment: [nextPaymentDate]
WHAT YOU CAN DO:
• View payment plan details: [dashboardUrl]
• Pay remaining balance early (no penalty)
• View your registration status
Thank you for your payment!
The Hockey Association Team
LOOPS_PAYMENT_PLAN_PAYMENT_FAILED_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of registrationseasonName - Season name and datescategoryName - Registration categoryfailedAmount - Amount that failed to charge (formatted as "25.00")installmentNumber - Which installment failed (2, 3, or 4)attemptCount - Current attempt number (e.g., 1, 2, 3)maxAttempts - Maximum retry attempts (always 3)nextRetryDate - When we'll retry (formatted as localized date string, or "No more retries")failureReason - Reason for failure (e.g., "Insufficient funds", "Card declined")dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
We were unable to process your payment plan installment.
PAYMENT ATTEMPT DETAILS:
- Registration: [registrationName] ([seasonName])
- Category: [categoryName]
- Payment [installmentNumber] of 4
- Amount: $[failedAmount]
- Attempt: [attemptCount] of [maxAttempts]
- Reason: [failureReason]
WHAT HAPPENS NEXT:
• We'll automatically retry: [nextRetryDate]
• Your registration remains active regardless of payment status
• You can update your payment method or pay manually
WHAT YOU CAN DO:
• Update payment method: [dashboardUrl]
• Pay remaining balance now to avoid retry attempts
• Contact us if you need payment arrangement assistance
Questions? Reply to this email anytime.
The Hockey Association Team
LOOPS_ADMIN_PAYMENT_PLAN_FAILURE_TEMPLATE_ID)Sent to every admin (unless they opted out via preferences.emailNotifications.paymentPlanFailures = false) each time a payment plan installment fails to charge. Subject: {testEmailPrefix}Payment Plan Failure: {userName} – {registrationName} (mark the prefix optional).
Data Variables:
adminName - Admin's full nameuserName - Member whose installment failedregistrationName - Name of registrationseasonName - Season name ("N/A" if the registration has no season)installmentNumber - Which installment failed (2, 3, or 4)totalInstallments - Installments in the plan (4)installmentAmount - Amount that failed, e.g. "$25.00"failureReason - Stripe's decline reasonattemptSummary - e.g. "Attempt 2 of 3 failed. 1 automatic retry remaining." or, on the last attempt, a note that retries are exhausted and Run Payments must be used after the member updates their carduserUrl - Link to the member's admin pagescheduledDate - Original scheduled date of this installment (DATE column, formatted without timezone shift, e.g. "10/16/2026")amountPaid - Total paid so far, e.g. "$162.50"remainingBalance - Balance still owed, e.g. "$487.50"dashboardUrl - Link to the dashboard footer button (/user)testEmailPrefix - Added automatically on every send ([TEST] outside production); use it in the SubjectTemplate Example:
Hi [adminName],
A payment plan installment failed for [userName].
- Registration: [registrationName] [seasonName]
- Installment: [installmentNumber] of [totalInstallments]
- Amount: [installmentAmount]
- Reason: [failureReason]
- Scheduled date: [scheduledDate]
- Paid so far: [amountPaid]
- Remaining balance: [remainingBalance]
[attemptSummary]
View member: [userUrl]
LOOPS_PAYMENT_PLAN_COMPLETED_TEMPLATE_ID)Data Variables:
userName - Customer's full nameregistrationName - Name of registrationseasonName - Season name and datescategoryName - Registration categorytotalAmount - Total amount paid (formatted as "100.00")completionDate - Date plan was completed (formatted as localized date string)dashboardUrl - Link to user dashboardTemplate Example:
Hi [userName],
Congratulations! Your payment plan has been completed successfully.
PAYMENT PLAN SUMMARY:
- Registration: [registrationName] ([seasonName])
- Category: [categoryName]
- Total Amount Paid: $[totalAmount]
- Completion Date: [completionDate]
YOUR REGISTRATION:
• Fully paid and confirmed
• No further payments due
• View registration: [dashboardUrl]
Thank you for completing your payment plan!
The Hockey Association Team
LOOPS_EMAIL_CHANGE_CONFIRMED_TEMPLATE_ID)Data Variables:
firstName - User's first name (formatted as "FirstName ([email protected])" for admin notifications)oldEmail - Previous email addressnewEmail - New email addressgoogleAuthWarning - Warning message if Google OAuth email differs from new email (empty string if no mismatch)supportEmail - Support contact emailorganizationName - Organization nameRecipients:
Template Example:
Hi [firstName],
Your email address has been successfully updated.
EMAIL CHANGE SUMMARY:
- Previous Email: [oldEmail]
- New Email: [newEmail]
[googleAuthWarning]
If you did not make this change, please contact us immediately at [supportEmail].
Thank you,
[organizationName]
Note on googleAuthWarning variable:
This variable will be empty if the user doesn't have Google OAuth linked, or if their Google email matches the new email. When present, it contains:
IMPORTANT: Your Google account ([email protected]) is still linked to this account but uses a different email address. If you no longer want to sign in with Google, you can unlink it in your Account Settings under "Account Security".
🚨 IMPORTANT: When adding new email functionality to the application:
EMAIL_EVENTS in src/lib/email/service.tssendRegistrationConfirmation())LOOPS_REGISTRATION_TEMPLATE_ID)This ensures all email templates are properly documented and maintainable.
By default, Supabase handles authentication emails (magic links, password resets) using their email service. For a more professional experience with custom branding and domain, you can configure custom SMTP using Google Workspace.
Prerequisites:
nycpha.org)[email protected])Step 1: Create App Password
abcd1234efgh5678)Step 2: Configure Supabase SMTP
Go to Supabase Dashboard → Authentication → Settings
Scroll down to SMTP Settings
Enable Custom SMTP toggle
Configure these settings:
SMTP Host: smtp.gmail.com
SMTP Port: 587
SMTP User: [email protected]
SMTP Pass: abcd1234efgh5678 (app password - no spaces)
Sender Email: [email protected]
Sender Name: NYC PHA
Click Save
Step 3: Add Email Logo (Optional)
Host your logo at https://yourdomain.com/images/logo-email.png
Go to Authentication → Email Templates
Customize templates with your logo:
<div style="margin-bottom: 30px;">
<img src="https://yourdomain.com/images/logo-email.png"
alt="Your Organization Logo"
style="max-width: 240px; height: auto; display: block;">
</div>
<h2>Welcome to your account!</h2>
<p>Please click <a href="{{ .SiteURL }}/auth/magic-confirm?token_hash={{ .TokenHash }}&type=magiclink">this link</a> to log in.</p>
<br><br>
<div>
<img src="https://yourdomain.com/images/logo-email.png"
alt="Your Organization Logo"
style="max-width: 240px; height: auto; display: block;">
</div>
Keep the token_hash/type link shape from Magic-Link Email Template when you customize — a branded template that falls back to {{ .ConfirmationURL }} breaks magic-link sign-in. Put the branded version in supabase/auth-templates/magic-link.html and re-run npm run auth:apply, so the next person's auth:apply doesn't reset your work.
Step 4: Test Configuration
Benefits of Custom SMTP:
Troubleshooting SMTP:
email_logs table in Supabase to verify delivery statusOn Vercel the function is frozen as soon as a route returns, so any email promise left floating after the response is silently dropped some of the time (#395, fixed in #398). The local ESLint rule local/no-floating-email-send (scripts/eslint-rules/no-floating-email-send.js) fails npm run lint (and CI) when one of these is neither awaited, returned, nor handed to something that owns it:
stage*Notification(s) / stage*Email(s), send*Notification(s) / send*Confirmation / send*Email(s), sendEmailImmediately, stageEmail, logEmailToDatabase, and any emailService.send* / emailService.stage* / emailStagingManager.* / emailProcessor.* send or stage callemail_logs writes: .from('email_logs').insert(...) / .upsert(...)It applies to src/app/api/**/route.ts, src/lib/email/**, src/lib/services/** and src/lib/**/*processor*.ts. It's a syntactic check, so it stays quiet about everything else.
How to comply:
// Needed before responding: await it
await emailService.sendWaitlistAddedNotification({ ... })
// Post-response work: schedule it so Vercel keeps the invocation alive
runAfterResponse('my-notifications', async () => {
await Promise.all([
stageCaptainRosterChangeNotification(...).catch((err) => logger.logSystem(..., 'warn')),
stageAdminNewRegistrationNotification(...),
])
})
A .catch() on its own doesn't count: the promise still floats. Assigning the promise to a variable is treated as handled, so make sure you await it later.
Escape hatch (rare; always say why):
// eslint-disable-next-line local/no-floating-email-send -- <reason this is safe>
The application uses Sentry for error monitoring and alerting, particularly for critical payment issues where payments succeed but database operations fail.
membership-system - Single project using environmentsBoth Development & Production:
NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn_here
SENTRY_ORG=your_sentry_org
SENTRY_PROJECT=membership-system
Production/CI only (used at build time to upload source maps for readable stack traces; not needed for local dev):
SENTRY_AUTH_TOKEN=your_sentry_auth_token_here
Generate this token from Sentry's Settings → Auth Tokens with the Source Map Upload and Release Creation scopes. Without it, builds still succeed, but Sentry stack traces show minified code instead of your original source.
The system automatically uses NODE_ENV to separate development and production events into different environments within the same project.
There are two ways to send an error to Sentry, and a rule about a third:
logger.error(...) / the category helpers (logger.logPaymentProcessing(..., 'error'), logXeroSync, logSystem, etc. — see src/lib/logging/logger.ts) — the default choice for normal error logging. 'error'-level calls are automatically reported to Sentry; info/warn/debug are not, unless the call site explicitly uses logger.reportWarningToSentry(...). When you're in a catch block and have the real caught value, pass it as the last argument so Sentry gets the real stack/type instead of a synthetic one built from the message string:
} catch (error) {
logger.logPaymentProcessing(
'operation-name',
'Human-readable message',
{ paymentIntentId, error: error instanceof Error ? error.message : String(error) }, // for console/file/admin-log display
'error',
error // the real caught value, for Sentry - can be omitted if there isn't one (e.g. a business-rule check with no exception)
)
}
metadata.error (the string) and the trailing error argument (the real value) serve different consumers — don't conflate them. Setting metadata.error to the raw error object instead of .message will silently blank it out in console/file/admin-log output, since JSON.stringify(new Error(...)) serializes to {}.
src/lib/sentry-helpers.ts (captureSentryError, captureSentryMessage, and the payment/account-deletion wrappers built on them) — use this instead of the logger when you need automatic user/session context (it looks up the current Supabase user and profile) or request context (browser/OS/device/IP) attached to the event. This is what the two "payment succeeded but database write failed" call sites use, since that's the highest-stakes error path in the app.
Never call Sentry.captureException/captureMessage directly. Always go through one of the two paths above. If you have a specific reason you must call the Sentry SDK directly, call scheduleSentryFlush() from src/lib/sentry-flush.ts immediately afterward.
Why the last rule matters: Sentry.captureException/captureMessage only enqueue an event on an internal transport; the actual network send happens asynchronously and isn't awaited by those calls. On Vercel, the serverless function is frozen as soon as the response is sent - if the send hasn't completed by then, it's silently cut off and the event never arrives, no error thrown. This isn't hypothetical: it's what caused #368, where logger.error calls in the Stripe webhook routinely never reached Sentry. scheduleSentryFlush() calls Sentry.flush() (which waits for delivery) inside Next.js's after() (which keeps the invocation alive long enough for that wait, without delaying the response itself). Both logger.ts and sentry-helpers.ts already do this for you - it's only direct Sentry.* calls that need to opt in manually.
The system automatically captures and alerts on:
Development Environment: Minimal alerts for testing Production Environment:
The application uses Stripe in test mode for development. Use these test card numbers to simulate different scenarios:
4242 4242 4242 42424000 0566 5566 55565555 5555 5555 44443782 822463 100054000 0000 0000 00024000 0000 0000 99954000 0000 0000 99874000 0000 0000 99794000 0000 0000 00694000 0000 0000 01274000 0000 0000 011912/34)123)12345)4242 4242 4242 4242 to test the complete flow and email delivery4000 0000 0000 0002 to test error handling and user notificationsemail_logs table in Supabase after each testThe application supports Stripe Link for one-click payments:
4242 4242 4242 4242 with email [email protected]src/
├── app/ # Next.js app directory
├── components/ # React components
├── lib/ # Utilities and services
│ ├── email/
│ │ ├── processor.ts # Email processing for payment completion
│ │ ├── service.ts # Email integration service
│ │ └── staging.ts # Email staging and batch processing
│ ├── supabase.ts # Database client
│ └── stripe.ts # Payment processing
├── proxy.ts # Authentication proxy (middleware)
└── types/ # TypeScript type definitions
The application uses Jest with TypeScript support for comprehensive unit and integration testing.
Configuration:
ts-jest preset@/ aliases resolve to src/ directoryKey Files:
jest.config.js - Jest configuration with TypeScript supportjest.setup.js - Global test setup and mockssrc/__tests__/ - Test files organized by featureIf you're setting up Jest for the first time or encountering issues, follow these steps:
# Install Jest and TypeScript support
npm install --save-dev jest ts-jest @types/jest
# Install additional testing utilities (optional)
npm install --save-dev @testing-library/jest-dom
Create jest.config.js in your project root:
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{js,jsx,ts,tsx}'
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/__tests__/**',
'!src/**/node_modules/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
testTimeout: 10000,
verbose: true,
}
Create jest.setup.js in your project root:
// Mock environment variables
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'
// Mock Next.js modules that aren't available in test environment
jest.mock('next/headers', () => ({
cookies: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
})),
}))
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
})),
useSearchParams: jest.fn(() => ({
get: jest.fn(),
})),
}))
Add test scripts to your package.json:
{
"scripts": {
"test": "npx jest",
"test:watch": "npx jest --watch",
"test:coverage": "npx jest --coverage"
}
}
Issue: "moduleNameMapping" validation warning
moduleNameMapper (not moduleNameMapping) in jest.config.jsIssue: Cannot find module '@/lib/...' errors
moduleNameMapper is correctly configured with '^@/(.*)$': '<rootDir>/src/$1'Issue: WSL/Windows path compatibility problems
npx jest in package.json scripts instead of direct jest commandIssue: Permission errors during npm install
npx jest instead of global jest installationIssue: TypeScript compilation errors in tests
ts-jest is installed and configured in jest.config.jsIssue: Cannot find module '@jest/globals' errors
@jest/globals imports from test filesdescribe, it, expect, jest, beforeEach) are available automaticallyimport { describe, it, expect, jest } from '@jest/globals' to just import your actual modulesTest your Jest configuration:
# Run Jest to verify setup
npm test
# Should show: "No tests found" or run existing tests
# If you see configuration errors, review the steps above
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run specific test file
npm test -- user-alternate-registrations.test.ts
# Run tests matching pattern
npm test -- --testNamePattern="should require authentication"
Tests are organized by feature and API endpoint:
src/__tests__/
├── api/
│ ├── admin/
│ │ └── registrations/
│ │ └── alternates.test.ts # Admin alternate management
│ └── user-alternate-registrations.test.ts # User alternate registration
└── components/ # Component tests (future)
API Endpoints Tested:
/api/admin/registrations/[id]/alternates)
/api/user-alternate-registrations)
Test Categories:
The test suite includes comprehensive mocking for external dependencies:
Supabase Mocking:
// Mocked database operations
const mockSupabase = {
from: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
single: jest.fn()
}))
}
Service Mocking:
When adding new API endpoints or features:
Create test file in appropriate __tests__ directory
Follow naming convention: feature-name.test.ts
Include test categories:
describe('/api/your-endpoint', () => {
describe('POST - Create resource', () => {
it('should require authentication', async () => {
// Test authentication requirement
})
it('should validate required fields', async () => {
// Test input validation
})
it('should successfully create resource', async () => {
// Test successful operation
})
})
})
Mock external dependencies using Jest mocks
Test both success and failure scenarios
Verify proper error responses and status codes
Tests use mocked environment variables configured in jest.setup.js:
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-role-key'
Planned additions to the test suite:
src/lib/email/service.ts): Handles all email communicationssrc/lib/supabase.ts): Database operations and authsrc/lib/stripe.ts): Payment processingsrc/lib/sentry-helpers.ts): Error monitoring and alertingemail_logs table for delivery statusAll issue tracking goes through GitHub Issues.
Open an issue to report a bug, propose a feature, or track work in progress.
Design discussion belongs in the issue thread and its linked pull requests, so
that tracking, review, and history stay in one place. Don't add planning or
feature-spec markdown files to the repo for this purpose — the docs/
directory is for durable reference documentation (architecture, setup guides,
standards, troubleshooting), not for tracking work.
The application is designed to deploy on Vercel with optimal Next.js integration and built-in cron job support for Xero token management.
npm install -g vercel
vercel login
# From your project directory
vercel --prod
Follow the prompts:
In your Vercel dashboard, add all environment variables from your .env.local.
Replace your-domain with:
Application Configuration:
NEXT_PUBLIC_SITE_URL=https://your-domain.vercel.app
CRON_SECRET=your_random_cron_secret_for_cron_jobs
Database & Authentication:
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
Google OAuth is configured in the Supabase Dashboard (Authentication → Providers), not via app environment variables.
Note: For Supabase API keys, use the new API keys (not legacy) from your production Supabase project (not development). Go to Settings → API → "API Keys" tab and create new keys if needed.
Payment Processing:
STRIPE_SECRET_KEY=rk_live_your_stripe_restricted_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_your_stripe_live_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_production_webhook_secret
STRIPE_API_VERSION=2026-09-30.endive
STRIPE_SECRET_KEY)STRIPE_SECRET_KEY is a restricted key (rk_test_... / rk_live_...), not a standard sk_... secret key. Create it in Stripe Dashboard → Developers → API keys → Create restricted key, start from the One-time payments template, and make sure it has at least the permissions below. Use one key per Stripe mode and per Vercel environment. The app reads it only server-side, in src/lib/stripe/server-client.ts. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (pk_...) stays a normal publishable key. Elements uses it in the browser through src/lib/stripe-client.ts.
| Resource | Permission | Why the app needs it |
|---|---|---|
| PaymentIntents | Write | Create membership and registration payments, plus off-session charges for payment plans, waitlist and alternates. Retrieve intents in confirm routes, Xero invoice creation and the webhook. |
| SetupIntents | Write | Save a card for later (/api/create-setup-intent, /api/confirm-setup-intent) |
| Customers | Write | Create the Stripe customer when a card is saved. Delete it on account deletion. |
| PaymentMethods | Write | Retrieve card details. Attach saved cards. Detach on card removal or account deletion. List cards on account deletion. |
| Refunds | Write | Admin refunds (/api/admin/refunds, /api/admin/refunds/process) |
| Charges | Read | The webhook retrieves charges, including expanded latest_charge and refunds. |
| Balance transactions | Read | The webhook reads Stripe fees for the Xero fee sync (balanceTransactions.retrieve, expand: balance_transaction). |
Not needed: Subscriptions, Invoices, Prices, Products, Checkout Sessions, Events, Webhook Endpoints, Payouts, Disputes and Connect. Leave them as None unless a template sets them. Webhook signature verification (webhooks.constructEvent) uses STRIPE_WEBHOOK_SECRET, not an API permission. Xero sync and scripts/ make no Stripe API calls of their own. stripe listen (local testing) uses your Stripe CLI login, not this key.
Rotating or replacing the key:
STRIPE_SECRET_KEY separately for each environment. Never commit or paste key values.The provided key 'rk_...' does not have the required permissions for this endpoint. If one appears, add the named permission to the key (Dashboard → key → Edit). There's no need to create a new key.Webhook signing secret (STRIPE_WEBHOOK_SECRET): each webhook endpoint has its own signing secret (whsec_...), so each environment and endpoint needs its own value. To roll it: open Stripe Dashboard → Webhooks → endpoint → Roll secret, choose an expiry for the old secret, set the new value in that Vercel environment, redeploy, and confirm a test event returns 200.
Email & Monitoring:
LOOPS_API_KEY=your_loops_api_key
LOOPS_EMAIL_BATCH_DELAY_MS=1000
LOOPS_WELCOME_TEMPLATE_ID=your_welcome_template_id
LOOPS_MEMBERSHIP_PURCHASE_TEMPLATE_ID=your_template_id
LOOPS_MEMBERSHIP_EXPIRING_TEMPLATE_ID=your_membership_expiring_template_id
LOOPS_PAYMENT_FAILED_TEMPLATE_ID=your_payment_failed_template_id
LOOPS_PAYMENT_METHOD_REMOVED_TEMPLATE_ID=your_payment_method_removed_template_id
LOOPS_PAYMENT_PLAN_PRE_NOTIFICATION_TEMPLATE_ID=your_payment_plan_pre_notification_template_id
LOOPS_PAYMENT_PLAN_PAYMENT_PROCESSED_TEMPLATE_ID=your_payment_plan_payment_processed_template_id
LOOPS_PAYMENT_PLAN_PAYMENT_FAILED_TEMPLATE_ID=your_payment_plan_payment_failed_template_id
LOOPS_PAYMENT_PLAN_COMPLETED_TEMPLATE_ID=your_payment_plan_completed_template_id
LOOPS_REGISTRATION_CONFIRMATION_TEMPLATE_ID=your_registration_template_id
LOOPS_WAITLIST_ADDED_TEMPLATE_ID=your_waitlist_template_id
LOOPS_WAITLIST_SELECTED_TEMPLATE_ID=your_waitlist_selected_template_id
LOOPS_ALTERNATE_SELECTION_TEMPLATE_ID=your_alternate_selection_template_id
LOOPS_ALTERNATE_REGISTRATION_CONFIRMATION_TEMPLATE_ID=your_alternate_registration_confirmation_template_id
LOOPS_EMAIL_CHANGE_CONFIRMED_TEMPLATE_ID=your_email_change_confirmed_template_id
LOOPS_REFUND_TEMPLATE_ID=your_refund_template_id
LOOPS_TEAM_MESSAGE_TEMPLATE_ID=your_team_message_template_id
LOOPS_CAPTAIN_ASSIGNMENT_NOTIFICATION_TEMPLATE_ID=your_captain_assignment_template_id
LOOPS_CAPTAIN_REMOVAL_NOTIFICATION_TEMPLATE_ID=your_captain_removal_template_id
LOOPS_CAPTAIN_ROSTER_CHANGE_TEMPLATE_ID=your_captain_roster_change_template_id
LOOPS_ADMIN_NEW_REGISTRATION_TEMPLATE_ID=your_admin_new_registration_template_id
LOOPS_ADMIN_REFUND_TEMPLATE_ID=your_admin_refund_template_id
LOOPS_ADMIN_PAYMENT_PLAN_FAILURE_TEMPLATE_ID=your_admin_payment_plan_failure_template_id
NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn_here
SENTRY_ORG=your_sentry_org
SENTRY_PROJECT=membership-system
SENTRY_AUTH_TOKEN=your_sentry_auth_token_here
Xero Integration:
Important: Create separate Xero apps for production and preview environments to avoid tenant conflicts.
Create Production Xero App:
https://your-production-domain.com/api/xero/callbackCreate Preview Xero App:
https://your-preview-domain.vercel.app/api/xero/callback# Use production app credentials for production environment
XERO_CLIENT_ID=your_production_xero_client_id_here
XERO_CLIENT_SECRET=your_production_xero_client_secret_here
XERO_REDIRECT_URI=https://your-domain.com/api/xero/callback
XERO_SCOPES=accounting.transactions accounting.contacts accounting.settings offline_access
The app's login screen (magic link/OTP, Google OAuth, or passkey) can't be driven by automated testing tools — none of those methods are automatable, and passkeys are broken on preview URLs entirely (RP ID mismatch). GET /api/preview-auth lets an automated tester (e.g. an AI agent driving a browser) obtain a real, valid session on preview deployments only, by signing in as a dedicated test user with signInWithPassword. It 404s immediately (never 401/403/redirects, so it can't be fingerprinted) unless VERCEL_ENV === 'preview' and a matching secret is supplied — it can never activate in production.
Setup (one-time, manual):
openssl rand -hex 32 — this becomes PREVIEW_AUTH_SECRET.PREVIEW_AUTH_SECRET, PREVIEW_TEST_USER_EMAIL, PREVIEW_TEST_USER_PASSWORD (and optionally PREVIEW_TEST_ADMIN_EMAIL/PREVIEW_TEST_ADMIN_PASSWORD for admin-page testing), checking only the "Preview" environment box. Leave Production unchecked — double-check by filtering the env var list to Production and confirming these keys are absent.PREVIEW_TEST_USER_PASSWORD.users table (preview project), with onboarding_completed_at set and is_admin set appropriately. Simplest way: log in once as the test user through the real magic-link UI and complete onboarding normally.PREVIEW_AUTH_SECRET (regenerate, update the Preview-scoped env var, redeploy) any time it might have leaked.Never commit the real secret or test-user password anywhere — .env.example only lists the variable names with blank/placeholder values. The real values live in Vercel's Preview-scoped env vars (encrypted at rest). To let someone else (a teammate, or the automation/LLM they're running) use the bypass, they should pull it themselves rather than have it pasted into chat/Slack/etc.:
vercel login, then vercel link inside the repo to connect it to nycpha/membership-system, then vercel env pull --environment=preview to write the current values (including PREVIEW_AUTH_SECRET) to a local .env file — never manually retype or forward the value."Their own machine" is load-bearing. This needs a vercel CLI that can either reuse a cached login or complete the interactive OAuth flow — neither is available in a fresh Claude Code on the web / cloud session, so an AI agent running there can't pull PREVIEW_AUTH_SECRET itself this way, and therefore can't call /api/preview-auth on its own. Enabling the Vercel MCP connector in that session doesn't help either — it covers project/deployment management, not reading environment variable values. The secret still has to be pulled on a machine with real CLI auth and handed to the agent's session (e.g. as an env var on the environment, or a .env.local it's given), never pasted as plaintext into chat.
Sensitive flag — these vars split into two different tiers:
PREVIEW_AUTH_SECRET must stay non-Sensitive. Devs/LLMs need to know its value to call the endpoint at all, so it has to remain pull-able via vercel env pull / visible in the dashboard.PREVIEW_TEST_USER_PASSWORD and PREVIEW_TEST_ADMIN_PASSWORD should be marked Sensitive. Nobody needs to read these back out — the route reads them server-side and calls signInWithPassword internally, the caller never sees or needs the password itself. They're also real Supabase credentials that could authenticate directly against the Supabase API, not just through this route, so there's no reason to leave them human-readable. Marking a var Sensitive doesn't stop the deployed app from using it, it only stops anyone (including you) from reading the value back out afterward — set it once, and if you ever need to change it, overwrite it blind rather than editing it.PREVIEW_TEST_USER_EMAIL / PREVIEW_TEST_ADMIN_EMAIL aren't secrets (they're fake addresses that never receive mail — see setup step 3) and don't need the Sensitive flag either way.Usage: navigate to https://<preview-url>/api/preview-auth?secret=<PREVIEW_AUTH_SECRET>&role=member (or role=admin) — it sets session cookies and redirects to /dashboard (or /admin). No changes are needed to the normal login flow, and src/proxy.ts is untouched.
Supabase Authentication:
https://your-domain.vercel.apphttps://your-domain.vercel.app/auth/callbackhttps://your-domain.vercel.app/** (wildcard for all auth flows)Stripe Webhooks:
https://your-domain.vercel.app/api/stripe-webhookSTRIPE_WEBHOOK_SECRETGoogle OAuth:
https://your-domain.vercel.app/api/auth/callback/googleXero OAuth:
https://your-domain.vercel.app/api/xero/callbackAutomatic Features (Vercel handles these):
Test Your Deployment:
NEXTAUTH_URL to your custom domainCRON_SECRET environment variable configuredvercel.json)Contributors normally deploy previews through the shared nycpha/membership-system project. If you instead connect a personal/sandbox Vercel project to test a preview deploy, two things will bite you:
Empty placeholder vars silently survive a bulk paste-import. When you first connect a GitHub repo, Vercel auto-creates empty placeholder environment variables for every key it detects in .env.example. A bulk "Import .env" paste into the dashboard silently skips any key that already exists — even when the existing value is empty. The result is that vercel env ls shows every expected name present while several are still blank, and the failure only surfaces much later as a build crash from a module-level guard (Error: Missing Xero environment variables, Error: supabaseUrl is required.), one variable at a time.
Editing the value in place through the dashboard did not reliably persist either. Delete and recreate the specific variable instead, piping the value on stdin so the secret doesn't land in your shell history:
npx vercel env rm KEY_NAME preview -y
printf %s "$KEY_NAME" | npx vercel env add KEY_NAME preview
vercel.json's cron schedules block Hobby-tier deploys. Two of the crons in vercel.json run more often than daily (/api/cron/xero-sync every 5 minutes, /api/cron/email-sync every minute), which Hobby-tier Vercel accounts don't allow. A personal non-Pro project fails at the "Deploying outputs" step — after a full successful build, so every retry burns the build time — with:
Error: Hobby accounts are limited to daily cron jobs. This cron expression (*/5 * * * *)
would run more than once per day. Upgrade to the Pro plan to unlock all Cron Jobs features on Vercel.
vercel deploy --local-config <crons-free-vercel.json> does not work around this; the remote build re-reads the real vercel.json from the uploaded source tree regardless.
Don't edit vercel.json to get around it. Dirtying a tracked file to make a deploy succeed is easy to commit by accident, and you don't need the crons anyway: every scheduled job except cleanup is also triggerable from the admin UI on a running preview deployment, which is the better way to exercise them.
| Cron path | Schedule | Manual trigger |
|---|---|---|
/api/cron/xero-sync | */5 * * * * | Admin dashboard (/admin) → Sync Invoices and Payments |
/api/cron/email-sync | * * * * * | Admin dashboard (/admin) → Sync Emails |
/api/cron/sync-xero-accounts | 3 2 * * * | Admin dashboard (/admin) → Sync Accounting Codes, or /admin/xero-integration → Accounts |
/api/cron/payment-plans | 6 2 * * * | Payment Plans report (/admin/reports/payment-plans) → Run Payments |
/api/cron/membership-reminders | 9 2 * * * | Membership Reports (/admin/reports/memberships) → Send Reminder Emails Now |
/api/cron/daily-housekeeping | 0 2 * * * | No UI trigger — call the endpoint directly with Authorization: Bearer $CRON_SECRET |
So: deploy to a Hobby project only if you actually need the crons registered (you almost certainly don't — upgrade the project to Pro in that case), and otherwise validate the preview against the shared team project, or exercise the jobs through the admin UI above.
Membership expiration reminders. /api/cron/membership-reminders emails a member when their membership's valid_until is exactly 30, 14, 7, or 1 days away (MEMBERSHIP_REMINDER_THRESHOLDS_DAYS in src/lib/services/membership-reminder-processor.ts). These are hardcoded, not admin-configurable, and matched by strict equality rather than <= — the same trick the payment-plan pre-notification job uses instead of a dedup table. That means a member gets at most one reminder per threshold, but it also means a day the cron doesn't run is a reminder that's simply never sent (no catch-up on the next run). Keep that in mind before changing the schedule or the threshold list.
The application uses Vercel Cron jobs for background processing. Vercel Pro plan is required for the advanced cron job scheduling used in this system.
1. Upgrade to Vercel Pro:
2. Configure CRON_SECRET Environment Variable:
Go to your Vercel project dashboard
Navigate to Settings → Environment Variables
Add CRON_SECRET with a random string value
Generate a secure random string:
openssl rand -base64 32
Set the value for Production environment
3. Verify Cron Jobs in Vercel Dashboard:
vercel.json:
xero-sync - Every 5 minutes (Xero invoice/payment sync)email-sync - Every minute (staged email processing, limit 100 per batch; transient failures stay pending and retry on the next run)daily-housekeeping - Daily at 2 AM (expire abandoned registrations, abandon stale Xero carts, prune email logs past 90-day retention)sync-xero-accounts - Daily at 2:03 AM (refresh Xero chart of accounts)payment-plans - Daily at 2:06 AM (process scheduled payment plan charges)membership-reminders - Daily at 2:09 AM (email members whose membership expires in exactly 30, 14, 7, or 1 days)Note: Cron jobs will not appear until the Pro plan is fully active and CRON_SECRET is configured.
2. Verify CRON_SECRET Environment Variable:
CRON_SECRET is set in your Vercel environment variables3. Test Cron Jobs (Optional): You can manually test cron jobs using curl:
# Test email sync operations (replace with your domain and secret)
curl -X GET https://your-domain.vercel.app/api/cron/email-sync \
-H "Authorization: Bearer your-cron-secret"
# Test Xero sync operations
curl -X GET https://your-domain.vercel.app/api/cron/xero-sync \
-H "Authorization: Bearer your-cron-secret"
# Test daily housekeeping operations
curl -X GET https://your-domain.vercel.app/api/cron/daily-housekeeping \
-H "Authorization: Bearer your-cron-secret"
4. Monitor Cron Job Execution:
Note: Vercel Hobby accounts are limited to daily cron schedules. Because vercel.json declares two sub-daily crons, a Hobby-tier project cannot deploy this repo at all — the deploy fails after the build with Hobby accounts are limited to daily cron jobs. See Setting up a personal Vercel project for preview deploys for how to work with a Hobby project without editing vercel.json.
Vercel Dashboard:
Application Health:
Build Failures:
Runtime Errors:
Email Issues:
email_logs tableXero Sync Issues:
The system includes several debugging tools to help troubleshoot issues with user payments, Xero sync, and system operations.
Located in scripts/debug/, these scripts help investigate specific issues:
debug-user-invoices.js - Investigate a specific user's invoices and payments:
# Usage: node debug-user-invoices.js <user_id>
node scripts/debug/debug-user-invoices.js 79e9a75e-2580-4d56-8d10-d1a6f8542118
What it shows:
debug-user-sync-logs.js - Investigate Xero sync operations for a specific user:
# Usage: node debug-user-sync-logs.js <user_id>
node scripts/debug/debug-user-sync-logs.js 79e9a75e-2580-4d56-8d10-d1a6f8542118
What it shows:
Before running debug scripts, ensure your environment is configured:
# Set up environment variables
export NEXT_PUBLIC_SUPABASE_URL="your_supabase_url"
export SUPABASE_SERVICE_ROLE_KEY="your_service_role_key"
# Or use .env.local file
source .env.local
Duplicate Xero Invoices:
# Check if a user has duplicate invoice numbers
node scripts/debug/debug-user-invoices.js <user_id>
Failed Xero Sync:
# Check sync logs for failed operations
node scripts/debug/debug-user-sync-logs.js <user_id>
Payment Processing Issues:
# Check all payment and invoice data for a user
node scripts/debug/debug-user-invoices.js <user_id>
The admin interface also provides debugging tools:
/admin/logs - View system logs and error messages/admin/xero-integration - Monitor Xero sync status and retry failed operations/admin/reports/financial - Review payment and invoice dataFor direct database access, use the Supabase dashboard:
For questions about the codebase or setup process, refer to:
docs/PLANNING.md for detailed architecturesupabase/schema.sql for data modelsemail_logs table for debuggingThe system includes automatic Xero integration for seamless accounting and bookkeeping.
📚 Important Resources:
https://yourdomain.com)http://localhost:3000/api/xero/callbackhttps://yourdomain.com/api/xero/callbackaccounting.transactions - Create and manage invoicesaccounting.contacts - Create and manage contactsaccounting.settings - Read chart of accountsoffline_access - Refresh tokensAdd these to your .env.local:
XERO_CLIENT_ID=your_xero_client_id_here
XERO_CLIENT_SECRET=your_xero_client_secret_here
XERO_REDIRECT_URI=http://localhost:3000/api/xero/callback
XERO_SCOPES=accounting.transactions accounting.contacts accounting.settings offline_access
npm run dev/admin/xero-integrationChart of Accounts Setup:
MEMBERSHIP - Membership revenue accountREGISTRATION - Registration revenue accountDONATION - Donation revenue accountSTRIPE - Stripe bank account for deposit trackingSTRIPE_FEES - Expense account for processing feesDISCOUNT-SCHOLAR - Scholarship discount trackingDISCOUNT-BOARD - Board member discount trackingBank Account Configuration:
STRIPE for automatic payment recordingImportant: These configurations should already be complete for your account, but here's the complete setup for reference.
Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID
Authorized JavaScript origins:
http://localhost:3000
https://membership-system-nycpha-preview.vercel.app
https://my.nycpha.org
Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google
https://membership-system-nycpha-preview.vercel.app/api/auth/callback/google
https://my.nycpha.org/api/auth/callback/google
Supabase Dashboard → Authentication → URL Configuration
Site URL:
https://my.nycpha.org
Redirect URLs:
https://my.nycpha.org/auth/callback
https://my.nycpha.org/**
For Development Environment:
Site URL: https://membership-system-nycpha-preview.vercel.app
Redirect URLs:
https://membership-system-git-*-nycpha.vercel.app/**
https://membership-system-nycpha-preview.vercel.app/**
http://localhost:3000/**
Note: The wildcard pattern /** includes /auth/callback, so specific callback URLs are not needed when using wildcards.
Supabase Dashboard → Authentication → Sign In / Providers → Google
Client ID: Your Google OAuth client ID Client Secret: Your Google OAuth client secret
Where to get Google OAuth credentials:
Note: You'll need to configure these settings for each Supabase project (development and production databases).
Security Settings:
Stripe Dashboard → Webhooks
You need separate webhook endpoints for each environment:
Production Webhook:
https://my.nycpha.org/api/stripe-webhook
Development/Preview Webhook:
https://membership-system-nycpha-preview.vercel.app/api/stripe-webhook
Feature Branch Testing (optional): For testing specific feature branches, you can create additional webhooks:
https://membership-system-git-[branch-name]-nycpha.vercel.app/api/stripe-webhook
Events to Send:
Subscribe each endpoint to these ten events. All are handled in src/app/api/stripe-webhook/route.ts except where noted.
| Event | What it drives in the code |
|---|---|
charge.refunded | Re-fetches the charge with its refunds. For each refund: if it's failed/canceled, releases it (see refund.failed). If our refunds row is already failed/cancelled, it is left alone and no email is sent (out-of-order delivery). Otherwise it marks the row completed (conditional update), moves its Xero credit note and payment from staged to pending for batch sync, and stages the refund notification email. A refund made directly in Stripe gets a new refunds row and an error log asking for a manual Xero credit note. |
charge.updated | Once the balance transaction exists, records the Stripe fee on payments.stripe_fee_amount and the matching full-payment xero_payments staging row. |
payment_intent.canceled | No handler. Not in the event switch, so it's acknowledged with 200 and ignored. Keep it subscribed only if you want it in the Stripe event log. |
payment_intent.payment_failed | Marks the payment failed, for alternate selections and for general payments (user registrations/memberships payment_status: 'failed'), and runs the payment completion processor with trigger_source: 'stripe_webhook_payment_failed' (failure email, cleanup). |
payment_intent.succeeded | Marks the payment completed with its Stripe fee and charge ID, then branches on metadata.purpose: waitlist_selection, alternate_selection, payment_plan_installment, payment_plan_early_payoff, or a normal membership/registration purchase (marks it paid and runs the payment completion processor for Xero staging and emails). |
payment_method.detached | Clears the saved card from the user (clearSavedPaymentMethod), removes their alternate selections, and stages the "payment method removed" email. A no-op if /api/remove-payment-method already cleared it. |
setup_intent.setup_failed | Sets users.setup_intent_status = 'failed'. |
setup_intent.succeeded | Saves the card on the user (stripe_payment_method_id, stripe_setup_intent_id, , and when present). |
Also handled, but not in the list above: charge.refund.updated uses the same handler as refund.updated. You don't need to subscribe to it: refund.updated already covers the same changes, and subscribing to both just delivers each change twice (harmless, since the handler is idempotent).
Setup: sandbox (test mode) first
https://membership-system-nycpha-preview.vercel.app/api/stripe-webhook (or a feature-branch preview URL, see above).STRIPE_WEBHOOK_SECRET in Vercel → Project → Settings → Environment Variables for Preview/Development. Redeploy so it takes effect. Never commit or paste the value.refund.updated). The delivery should show HTTP 200. A 400 means the signing secret doesn't match.stripe listen --forward-to localhost:3000/api/stripe-webhook locally and stripe trigger charge.refunded. Use the whsec_... secret that stripe listen prints as your local STRIPE_WEBHOOK_SECRET.completed and its credit note moves to pending.Setup: production (live mode)
https://my.nycpha.org/api/stripe-webhook.STRIPE_WEBHOOK_SECRET for the Production environment in Vercel, then redeploy./api/stripe-webhook after the first real payment or refund.Important:
STRIPE_WEBHOOK_SECRET)Xero Developer Portal → Your App → OAuth 2.0
Redirect URI:
https://my.nycpha.org/api/xero/callback
The system implements a robust, multi-stage payment processing pipeline that ensures data integrity, handles failures gracefully, and provides complete transparency into operations.
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
│ Stripe │ │ Payment Completion │ │ Xero Batch │
│ Webhook │───▶│ Processor │───▶│ Sync Manager │
└─────────────────┘ └─────────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────┐ ┌─────────────────┐
│ Core Business │ │ Email Staging & │ │ Rate Limited │
│ Records │ │ Processing │ │ API Calls │
└─────────────────┘ └─────────────────────┘ └─────────────────┘
The system implements a fire-and-forget email processing architecture optimized for serverless environments to ensure fast payment completion responses while maintaining reliable email delivery.
Payment Completion (Fast Response):
┌─────────────────────────────────────┐
│ PaymentCompletionProcessor │
│ ├─ Phase 1: Xero staging (awaited) │
│ ├─ Phase 2: Email staging (awaited) │
│ ├─ Phase 3: Email processing (fire-and-forget) │
│ ├─ Phase 4: Xero sync (fire-and-forget) │
│ └─ Phase 5: Discount usage (awaited) │
└─────────────────────────────────────┘
Background Email Processing:
┌─────────────────────────────────────┐
│ EmailProcessingManager │
│ └─ emailStagingManager.processStagedEmails() │
│ └─ Send emails one by one with delays │
└─────────────────────────────────────┘
Scheduled Tasks (Cron):
┌─────────────────────────────────────┐
│ Cron Endpoints │
│ ├─ /api/cron/email-sync │
│ └─ /api/cron/daily-housekeeping │
└─────────────────────────────────────┘
1. Single Source of Truth
PaymentCompletionProcessor handles staged email processing2. Fire-and-Forget Processing
3. Serverless-Optimized
setInterval calls4. Simple Email Processing
Email Staging Manager (src/lib/email/staging.ts)
Email Processing Manager (src/lib/email/batch-sync-email.ts)
Payment Completion Processor (src/lib/payment-completion-processor.ts)
Event: payment_intent.succeeded or payment_intent.payment_failed
Webhook Handler (src/app/api/stripe-webhook/route.ts):
user_memberships or user_registrations → payment_status: 'paid'payments → status: 'completed'Key Design Principle: Webhook focuses solely on core business record updates and delegates all complex processing to the Payment Completion Processor.
Service: src/lib/payment-completion-processor.ts
Responsibilities:
xero_invoices and xero_payments staging records'staged' → 'pending' → 'synced'Staging Record Search Logic:
// 1. Search by payment_id (for completed payments)
// 2. Search by user_id in staging_metadata (for staged records)
// 3. Search by xero_invoice_id (for invoice-first flow)
// 4. Handle null payment_id scenarios gracefully
Service: src/lib/xero/batch-sync.ts
Singleton Pattern with Concurrency Protection:
Sync Process:
Rate Limiting Strategy:
// Minimum 2-second delay between sync operations
private readonly MIN_DELAY_BETWEEN_SYNCS = 2000
// Automatic delay enforcement
if (remainingDelay > 0) {
console.log(`⏳ Rate limit protection: waiting ${remainingDelay}ms`)
await new Promise(resolve => setTimeout(resolve, remainingDelay))
}
Contact Management:
Invoice Generation:
Payment Recording:
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐
│ staged │───▶│ pending │───▶│ synced │───▶│ failed │
└─────────┘ └──────────┘ └─────────┘ └─────────┘
│ │ │ │
Initial Ready for Successfully Error state
creation Xero sync synced to (retryable)
(webhook Xero
triggered)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ processing │───▶│ completed │───▶│ synced │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
Payment in Payment Xero sync
progress successful complete
Invoices: All records with sync_status IN ('pending', 'staged')
Payments: Filtered by:
sync_status IN ('pending', 'staged')payments.status = 'completed'payments.payment_method != 'free'This ensures only completed, paid transactions are synced to Xero.
The admin interface provides manual sync options:
✅ Automated Bookkeeping - Eliminates manual invoice entry ✅ Accurate Fee Tracking - Stripe processing costs automatically recorded ✅ Professional Invoicing - Uses Xero's templates and delivery system ✅ Real-time Sync - Financial data synchronized every 2 minutes ✅ Discount Transparency - Clear breakdown of promotional pricing ✅ Audit Trail - Complete transaction history and error logging ✅ Staging-First Approach - Zero data loss with robust staging system ✅ Intelligent Filtering - Only syncs eligible payments (completed, non-free) ✅ Pro Plan Optimization - Leverages Vercel Pro for high-frequency processing
Xero Contact Constraints:
Our Contact Strategy:
Our system uses a name-first search strategy to prevent picking up the wrong contact when multiple contacts exist with the same email:
This prevents the system from picking up the wrong contact when you have multiple contacts with the same email address in Xero.
Our system follows an intelligent 5-step process to handle archived contacts while minimizing duplication:
Scenario 1: Perfect Match Found
Scenario 2: Legacy Contact Found
Scenario 3: Different Member Found
Scenario 4: Archived Contact with Exact Name
Scenario 5: No Alternatives - Create New
Common Issues:
Error Monitoring:
Problem Solved: Duplicate email processing and slow payment completion responses due to complex batch job systems.
Changes Made:
Performance Improvements:
Removed Components:
ScheduledBatchProcessor - Unused scheduled processing logicDatabase imports - Cleaned up importsOptimized Components:
BatchProcessor - Kept for Xero sync operations (actively used)EmailProcessingManager - Simplified email processing utilityPaymentCompletionProcessor - Fire-and-forget architectureCurrent Architecture:
Payment Completion (Fast):
┌─────────────────────────────────────┐
│ PaymentCompletionProcessor │
│ ├─ Phase 1: Xero staging (awaited) │
│ ├─ Phase 2: Email staging (awaited) │
│ ├─ Phase 3: Email processing (fire-and-forget) │
│ ├─ Phase 4: Xero sync (fire-and-forget) │
│ └─ Phase 5: Discount usage (awaited) │
└─────────────────────────────────────┘
Scheduled Tasks (Cron):
┌─────────────────────────────────────┐
│ Cron Endpoints │
│ ├─ /api/cron/xero-sync │
│ ├─ /api/cron/email-sync │
│ └─ /api/cron/daily-housekeeping │
└─────────────────────────────────────┘
Batch Processing (Xero Only):
┌─────────────────────────────────────┐
│ BatchProcessor │
│ └─ Used by Xero sync operations │
└─────────────────────────────────────┘
Tech Stack: Next.js 14, TypeScript, Tailwind CSS, Supabase, Stripe, Loops.so, Xero API
Add a SUPABASE_DB_URL secret to each of those two Environments — same name, different value. It goes under Settings → Environments → (the environment) → Environment secrets, not repository secrets: one name resolving to a different value per environment is the whole point, and repository secrets carry no approval gate.
This is a PostgreSQL connection string, not the API URL. They are different things and are not interchangeable:
| Variable | Example | What it is |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | https://fogsphzerhmyjckxhalj.supabase.co | HTTPS endpoint the JS client talks to. Public — it ships in the browser bundle. |
SUPABASE_DB_URL | postgresql://postgres.<ref>:<password>@<pooler-host>:5432/postgres | Postgres wire-protocol connection, used by psql and supabase db push. Contains the database password — secret. |
| Environment | Value |
|---|---|
supabase-production | production project (fogsphzerhmyjckxhalj) |
supabase-development | development project (qojixnzpfkpteakltdoa) |
Get each from Dashboard → Connect → Session pooler. The dialog shows the password as a literal [YOUR-PASSWORD] placeholder — it gives you the correct host, port and postgres.<project-ref> username, but you have to substitute the real database password yourself. The host's aws-N-<region> prefix is assigned per project and isn't derivable from the region, so copy it from the dialog rather than reusing another project's value.
That password is not displayed anywhere in the dashboard: Supabase shows it once at project creation and never again. If it isn't in your password manager, Settings → Database → Database password → Reset database password issues a new one, shown once.
Resetting is safe for the running application — it connects over HTTPS with NEXT_PUBLIC_SUPABASE_URL and the API keys, and never opens a Postgres connection. What it does break is any saved copy: scripts/clone-registration-simple.sh and the psql invocations in scripts/README.md read a DATABASE_URL that developers set locally, so anyone holding the old one needs the new value. Each project has its own password; resetting one does not affect the other.
Which connection to use:
db.<ref>.supabase.co:5432) — session mode, but IPv6-only unless the project has the IPv4 add-on, and GitHub-hosted runners are IPv4-only. Fine from a machine with IPv6; don't rely on it in CI.setup_intent_status = 'succeeded'stripe_customer_idrefund.failed | Releases the refund: sets our refunds row to failed, which frees its discount code for reuse, and sets its unsynced (staged/pending/failed) credit note and Xero payment to ignore. If the payment had been marked refunded, it is restored to completed (so the refund can be retried) unless the remaining completed refunds still cover the full amount. Its refunded registrations go back to paid when no other live refund remains. If the credit note is already synced to Xero, it logs an error asking for a manual void. Any DB error returns 500 so Stripe retries. Every write is conditional, so retries converge without repeating anything. |
refund.updated | Same release as refund.failed when the refund's status is failed (sets failed) or canceled (sets cancelled). Other statuses are ignored. |