Files
hackerspace.sh/content/docs/reference/payments-and-dues.md
T
Moheeb Zara 16229ae523 feat(docs): add a Diátaxis documentation section to the public site
A new public /docs area covering the whole platform, organized by Diátaxis:
tutorials (learning), how-to guides (tasks), reference (facts), and explanation
(the why). 29 pages, each grounded in the actual routes, actions, schema, and
UI — verified against the code and corrected for accuracy (e.g. admin-only vs
admin+board gating, where the permissions matrix actually lives, how Stripe
reconciliation matches).

Implementation:
- lib/docs/nav.ts is the single source of truth (categories + pages), driving
  the sidebar, static params, and prev/next.
- Content authored as content/docs/<category>/<slug>.md, read at build time
  (the routes are statically generated), rendered by <DocsMarkdown> — reuses the
  existing react-markdown/GFM stack, no new dependencies.
- (docs) route group with its own theme (docs.css) matching the public site's
  dark / monospace / lime aesthetic; sidebar with active-state, breadcrumb,
  prev/next, and a Diátaxis home. Public — signed-in users can read it too.
- "Docs" link added to the landing nav.

typecheck 0 · lint 0 errors · build ok (all /docs pages prerendered)
2026-08-05 00:42:42 -07:00

6.1 KiB
Raw Blame History

The payments and dues subsystem records money that comes into your space and maps it to members. This page is the authoritative reference for the payment enums, the advance-only dues rule, admin-configured pay-here links, and the Stripe billing fields. For the day-to-day workflow, see /payments and the member self-view at /me.

Payment platforms

The payment_platform enum tags every payment and every pay-here link with its origin. Only these five values exist.

Value Meaning Pay-here link In-app checkout
paypal PayPal Yes No
zeffy Zeffy Yes No
venmo Venmo Yes No
cash Cash logged by a treasurer No No
stripe In-app recurring dues integration No Yes

The url-based subset — paypal, zeffy, venmo — is the only set allowed for external pay-here links (DUES_LINK_PLATFORMS). cash and stripe cannot be configured as external dues links.

Every row in payments carries a payment_link_status, which records whether the payment has been matched to a member.

Value Meaning
unlinked Not yet matched to a member (the default)
linked Matched to a member in this space

Two columns hold this value: link_status is canonical, and status is kept for backward compatibility. Both default to unlinked. A cash payment logged with a member is inserted as linked; imported CSV rows and unmatched platform payments start unlinked. Linking via /payments sets link_status to linked and stamps member_id. The unlinked count drives the alert badge on the /dashboard.

Advance-only dues rule

When a linked payment updates a member's dues state, the state can only move forward in time. This is enforced by resolveDuesAdvance, which compares the member's existing last_paid_at against the incoming payment's transaction_date:

  • If the incoming payment is the same as or newer than what is on file, last_paid_at advances to the incoming date and the member's payment_status is set to current.
  • If the incoming payment is older than what is on file, last_paid_at is left unchanged and payment_status is not touched.

A backdated or historical payment therefore never moves a member's dues state backward. Comparison is by instant, not string order, so timezone offsets and Z-suffixed timestamps never disagree. The rule runs on both cash logging and payment linking.

Admins configure external pay-here links so members can pay dues off-platform; a treasurer reconciles the payment manually later through the /payments flow (the platform tag pre-types that reconcile). Configuration is link-only — no automated payment record is created.

Column Type Notes
platform payment_platform One of paypal, zeffy, venmo; unique per space
url text Must be an absolute https:// URL (DB CHECK url ~* '^https://')
instructions text Optional memo hint, e.g. "put your member name in the note"
is_active boolean Only active links render to members; defaults to true
sort_order integer Display order, 0–999; defaults to 0

There is at most one link per (space, platform); saving is an idempotent upsert on that pair. The /me self-view shows only active links, in sort_order; the /settings admin UI shows every configured method, active and inactive.

URL safety

Because url is rendered as a member-clickable anchor and admins can write the row directly through RLS, the https://-only rule is enforced at three layers: the DB CHECK constraint, the Zod schema (isSafeDuesUrl, max 500 chars), and the read boundary that feeds the clickable card. http://, javascript:, data:, and other non-https schemes are rejected everywhere.

Permissions

Operation Roles
View active links (/me) Any space member
View all links, create, update, delete (/settings) admin, board

Stripe billing fields (member_billing)

Stripe recurring dues uses per-space own keys (not Connect). Each member with a Stripe subscription has one row in member_billing.

Column Type Notes
stripe_customer_id text The member's Stripe customer
stripe_subscription_id text The active subscription
subscription_status text Raw Stripe subscription status
current_period_end timestamptz End of the paid period; drives the grace window

Two service-client paths write this table: the Stripe webhook, and startDuesCheckout, which upserts stripe_customer_id when it first creates the member's Stripe customer. SELECT is limited to admin, board, and treasurer via RLS. A member reads their own row through a validated service-client action (/me). The admin dues view lists every member's billing status.

Status mapping

Raw Stripe subscription.status maps to a member status. Dues lapse tops out at late — it never auto-sets inactive; an admin decides inactivation manually.

Stripe status Member status
active, trialing current
past_due (within grace) current
past_due (grace exceeded) late
canceled, unpaid, incomplete, incomplete_expired late

The grace window is current_period_end plus the configured grace_days (default 7). Stripe settings — mode (test/live), publishable key, the tier-to-price map, grace_days, and the secret/webhook refs — live in integrations.config; the secret key and webhook signing secret are stored in the secrets vault, not in config. See /settings to configure Stripe.