Skip to content

Conventions › Session Fees & Dual-Rail Payments ​

Status: Active Last reviewed: 2026-11-01

Canonical model (migrations 00298–00303, 2026-06-26). Moved here from CLAUDE.md 2026-08-03 (context-lean restructure); this file is the authority, CLAUDE.md points here.

Fee gate ​

  • sessions.participation_fee (fixed per-person ₩, 0 = free) is the canonical fee gate.
  • The legacy cost_total is a post-hoc session-split field — never use it as the fee gate.
  • U-06 (2026-09-17): the fee is editable on /sessions/[sessionId]/edit until the session's FIRST session_payments row exists — trg_block_fee_edit_with_holds (00404, HINT added 00649) is the server-side authority; the edit form mirrors it client-side via useSessionPayments.

Reserve-then-pay backing model ​

  • Fee state is backed by the existing session_payments table in a reserve-then-pay pattern: confirming an RSVP on a fee session auto-creates a pending hold (private.sync_session_payment_on_rsvp trigger, 00299).
  • One hold row backs both payment rails (method ('manual'|'portone')) — never add a second table or an RSVP enum for payment state.

Manual rail (입금) ​

  • The member taps 입금 완료 (attest_session_payment RPC → sets submitted_at, method='manual'); the host confirms receipt via the 정산 screen, flipping the row to paid.
  • The host roster surfaces each member's 입금 완료 as a warning badge (입금 확인 중) replacing the generic 입금 필요, and sorts submitted rows first, so the confirmer sees whose transfer to look for.
  • Confirmation authority is session host (created_by) OR club dues-manager (canManageDues) — never club-admin-only (00303): a 번개 host is created_by and usually not a club admin, so the 00059/00145 admin-only RLS + the canManageDues client gates had locked pickup hosts out of confirming their own fee roster. 00303 adds additive permissive session_payments host SELECT+UPDATE policies, and the 정산 entry + screen gates OR in session.createdBy === userId; markPaid stamps method='manual'.
  • The fee count's authority is spelled once per side, the two UPDATE policies spell it inline (00747). Whether someone may confirm a session's fees is private.can_confirm_session_fees in SQL (the 입금 확인 N건 count) and deriveSessionToolAccess().canSeeSessionMoney in the app. The two UPDATE policies on session_payments still spell the authority inline, and a per-club role_configs override of manage_dues would make the app and the server diverge; no writer of such an override exists today. If one is ever built, the policies, the helper and the predicate change together.
  • Hosts get no INSERT policy (holds are trigger-created; the legacy split insert stays a club-admin action).
  • The payer never confirms or waives their own row (00536): every manual-rail UPDATE policy (session_payments, dues) and the confirm_dues_payment RPC carry user_id <> auth.uid() — marking your own money received/forgiven has no independent witness. A member who legitimately owes nothing is settled by a different authority (host or canManageDues, not the payer) via markWaived, or by club policy (below) — never by self-touch.

Waiver & host-fee policy ​

  • 'waived' is a first-class confirmed state — the gate maps it to 참가 확정 (below). It means "no fee is owed for this participant," distinct from 'paid' (money received). Waive is triggered by an authority other than the payer (markWaived), never self-served.
  • Host-fee exemption is a per-club policy, not a per-session self-waive (clubs.host_fee_exempt, 00543). Default false = the host pays their share (the fair cost-split default: the host usually plays and consumes court time). When a club opts in, the RSVP trigger creates the host's own hold already 'waived' — a system policy action in SECURITY DEFINER context, which is why it does not violate the 00536 self-touch guard. A one-off exception for a specific session is handled the clean way: another admin/dues-manager (not the payer) waives the host via markWaived.
  • Authority for the flag is club-admin (the clubs_update RLS, same as every other fee/dues setting — dues_amount, default_guest_fee). It is a symmetric club-wide rule (every session host is treated identically, not a personal carve-out for the setter), so it sits inside the same admin trust model as dues_amount, which sets what all members pay and is already admin-editable. A 2026-09-02 adversarial audit flagged that a lone admin who also hosts can thereby comp themselves (H3/H5); this is intended trust delegation, not a privilege crossing (an admin can already set participation_fee = 0). If a club wants a higher bar, gate the column to owner-only via a BEFORE UPDATE trigger — not done by default, to stay consistent with the other money settings.

PortOne rail (Phase B, scaffolded) ​

  • The member checks out → the server independently verifies with PortOne and calls public.mark_session_payment_paid_via_portone (service_role-only, idempotent, amount-checked); the client never marks a payment paid.
  • Per KR-3, in-app payment is PortOne v2 WebView only — never a per-PG SDK.
  • Checkout is debuggable today via the mock PaymentServicePort adapter (createMockPaymentService, OTA-safe pure JS) for the dev account (dev_mock_pay_session) — the real @portone/react-native-sdk native adapter + EAS build + the verify-swap are deferred; see portone-payments.md.

Participant gate state (UI logic) ​

Derived via sessionPaymentGateState(fee, paymentStatus, submittedAt?) from @twomore/app — never re-derive inline:

StateMeaning
'free'no fee
'reserved'pending hold, not yet submitted = 입금 필요
'submitted'입금 완료 tapped, awaiting host confirm = 입금 확인 중
'confirmed'paid or waived = 참가 확정

Hold window & expiry ​

  • A confirmed-but-idle hold (submitted_at IS NULL) expires LEAST(created_at + sessions.payment_hold_minutes (default 10), session_start) after reservation via the */5 cron (expire_unpaid_session_holds, re-issued in 00301).
  • Tapping 입금 완료 stops the clock, so the host's confirm latency never races the timer.
  • On expiry the cron pre-seeds an EXCUSED attendance record (no strike), cancels the RSVP, and best-effort promotes the next waitlisted member.

The normal no-show strike fires, the fee stays paid, the 정산 roster flags the row with a 불참 badge — refund is the host's off-app call.

The refund on a self-cancel (00752, owner decision 6) ​

  • The server's clock decides, once, at the cancel. private.app_cancel_window_hours() (24) is the one constant; the classifier, the host's late-cancel alert and private.rsvp_cancel_refund_due(session) read it, and the cancel-policy parity gate (check:cancel-policy) holds the app's refundDueOnCancel / refundDeadline (domain/session-info-model/refund-window.ts) to it. rsvps.cancelled_at is client-supplied and is never used; the cancel Dialog's sentence comes from the device clock and may differ from the server's in the last seconds (recorded gap).
  • The refund opens on a cancel, for exactly three reasons (session_payments.refund_reason, never selectable by a raw client): session_cancelled (the whole session, its own path), player_cancelled (the player's own cancel with at least 24 hours left, the boundary second included) and host_chosen (a signed-in actor who is not the player cancels the seat, at any hour: a removal is not the player's choice). A cancel with no signed-in actor (the cron, the expiry sweep) keeps the fee, as before. A later self-cancel keeps the fee (참가비 유지: cancelled seat, status='paid', refundState='none').
  • The snapshot. A cancel made before the host confirmed a reported transfer records on the hold row whether a refund would be due; when the row later turns paid (the host's confirmation or the PortOne webhook) the opener decides by the snapshot, never by the confirmation's hour. An idle hold is dropped.
  • The host's exception is open_session_refund(p_payment_id): the same authority that confirms a payment (private.can_confirm_session_fees; the app's deriveSessionToolAccess().canSeeSessionMoney), never the payer, never the host's own row, for a paid row whose seat was cancelled; idempotent. There is no host "refunded" button: the payer's 환불받았어요 (acknowledge_session_refund) is the witness.
  • No re-join after a refund opened. A player whose refund is pending or acknowledged cannot take a seat again by any path (own re-join, a host seating, a waitlist promotion, an approved application): a BEFORE trigger on rsvps raises P0001 hint refund_after_cancel, and the page says so in its own line. A late canceller who kept the fee may re-join on the standing paid row.
  • Leaving or being removed from a club cancels the member's future seats, and the refund trigger acts on any signed-in actor's cancel, so a member who leaves is refunded their paid future seats only with the window left, and a member who is removed is refunded whatever the hour.
  • One notice per payment. session_refund_notice carries the reason in its body; host_refund_requested goes to the host for the player and host-chosen reasons (not when the host opened it) and resolves on the payer's acknowledgement; a later cancel of the whole session after a player's refund sends no second notice and refunds the late cancellers.
  • The rail. The manual rail is unchanged. A PortOne row opens the same obligation; settling it through PortOne waits for the payment rail (Phase 8, no real PortOne money exists): docs/release-evidence.md lists it under "Owed Before Launch".

A 매치's own account (00753, owner decision 7) ​

  • A pickup 매치 (no club) keeps its own transfer account in session_deposit_accounts (one row per session, cascade with it), never on sessions (open pickups are publicly readable) and never the host's pickup club's account.
  • Who writes: the host only, through set_session_deposit_account / clear_session_deposit_account, for a pickup with a fee that is not finished or cancelled. Who reads: get_session_deposit_account returns a row to the host or a confirmed seat (a seat inside its payment hold is confirmed) and nothing to a waitlisted viewer, a cancelled seat, an outsider or a club official who is not the host; anon has no EXECUTE. The table has no client grant except the host's own-row SELECT (for the personal data export).
  • The one read: useDepositAccount(session, { holdsSeat }) picks the 매치's account for a pickup and the club's account otherwise; no surface calls useClubBankAccount for a session. A waitlisted viewer's query stays idle.
  • A series copies the account to every occurrence in the same transaction (create_session_series input key deposit_account; the replay hash excludes it, so no digest of a number is stored).
  • When it is deleted: with the session, at once when the session is cancelled, 14 days after it ended (30 days at the latest while a reported transfer is unconfirmed) by the cron purge-session-deposit-accounts, and at the host's account deletion. A changed account notifies the seats that reported a transfer (session_deposit_account_changed, empty payload).
  • Never on the device's disk, never in a log: both account reads are persist-denied; the create wizard keeps the account in memory only (not in the persisted draft, not in the retry fingerprint). The review page shows it masked to its bank and last four digits. A paid 매치 with no account can be created (the soft Notice); the host adds it on 일정 수정.

Dues bulk actions ​

There are none. The flat v2 회비 관리 page (L3-11) confirms a reported transfer one row at a time, at once (its board draws no bulk verb), and mark_dues_paid_bulk (U-15, 00648: a plain officer UPDATE wrapped in a SECURITY INVOKER function, with RLS dues_update as its whole authorization) was dropped in 00722 (W4 batch 14) with the legacy roster that called it. An officer's raw UPDATE public.dues SET status = 'paid' stays the manual mark-paid path, and the ledger trigger (00706) still turns it into income.

Dues plans and periods (00705, 2026-11-01) ​

  • club_dues_plans is the one source of a club's dues terms: several plans per club, one active recurring plan (monthly / quarterly / semiannual / yearly) enforced by a partial unique index; one_time plans carry a name and a due_on; a per_session plan records the standard fee only — per-session fees are session_payments, never dues rows.
  • dues rows belong to a plan and a period (plan_id, period_start, period_end; year/month equal the period start); the key is (club_id, user_id, plan_id, period_start) NULLS NOT DISTINCT. The due date is due_day of the period's last month. The member's 회비 pane leads with the oldest open charge: the earliest due date (the period end when it has none), the recurring plan's row first on the same day, then the earlier period start (member-dues-model.ts); the officials' page picks its own charge and does not follow this.
  • Rows are generated only by private.generate_dues_period (idempotent): the officer RPC generate_club_dues or the hourly dues_tick_generate cron. No client inserts dues (the dues_insert policy is gone). A failing tick raises and retries the next hour; it never swallows.
  • clubs.dues_amount / dues_cycle / dues_day are read mirrors of the active recurring plan (an AFTER trigger syncs them; a direct UPDATE is 42501 dues_settings_via_plan); a priced club INSERT creates its plan in the same transaction. The columns leave in wave R.
  • Refusals carry stable hints: dues_plan_invalid (22023), dues_plan_exists, dues_plan_not_found, dues_period_invalid, dues_not_authorized, dues_plan_has_rows (P0001).
  • A one-off (한 번, 00750, owner decision 4) is charged at creation: the page saves the plan, then calls generate_club_dues(club, today, planId) (the cron is the backstop, never the path). A member who becomes active while the due date is today or later (in the club's local date) is charged by an AFTER trigger on club_members (private.charge_joiner_open_one_offs, which reuses private.generate_dues_period(plan, day, user); a one-off never generated is left to the officer or the cron, a recurring plan is untouched). A one-off with at least one dues row is locked on the server: a changed amount, start or due date is refused (dues_plan_has_rows); the name stays editable and deactivate_dues_plan leaves its rows. save_dues_plan takes a client-made id (an unknown uuid is inserted with it, an id of this club updates that plan, another club's id is dues_not_authorized), so a retry after a lost answer lands on the same plan; get_club_dues_plan also returns a stopped one-off that still holds rows (active: false; a member sees only their own) and the pages offer only active plans as choices. 일정마다 (a fee per session) is not built and the cadence picker does not offer it.

Club ledger (00706) ​

  • club_ledger_entries: opening (at most one per club), income, expense; officials only (is_club_admin) — a member-facing aggregate and its club setting are a W4 item.
  • Income rows are written only by triggers: one per confirmed payment_history row, one per manual mark-paid; unconfirm and unmark remove them; a waive writes none. The client never writes an income row. update_/delete_ledger_entry refuse income and opening rows (ledger_entry_locked).
  • The opening is one line with a date (00751, owner decision 5): officials add, fix (amount and date only) and remove it; income stays locked. The balance counts from the opening's date: the opening plus every entry dated on or after it, no opening = every entry; older entries stay listed with counted: false and the page draws one head over them (it never derives that from dates). get_club_ledger_summary and the list's tiles carry opening (null, or id, amount, entryDate), and the summary, the list's total and the export's last running balance are one figure. No entry is dated after the club's local today (22023 ledger_entry_invalid); the page's device today stands in for the club's (a club has no time zone field in the app) and the server decides.
  • Reads: list_club_ledger (incomes rolled up per plan and period, a day cursor, the balance as above), get_club_ledger_summary, export_club_ledger (CSV with BOM and a running balance, empty on an uncounted row; a formula-leading title is neutralised). The download is 장부 내려받기, never an import and never 내보내기 (that word removes a member): useDownloadClubLedger reads the CSV and hands it to the file-share port. A receipt is a photo, officials only: it is downscaled and re-encoded before upload (a phone: the image manipulator; the web app: a canvas, image-resize.web, so no location data survives either), uploaded to the private bucket club-ledger-receipts at <clubId>/<entryId>/<file> before the entry names it, and removed again when the server refuses the entry (a typed refusal only; any other failure leaves the object, the write may have landed). It is read through a signed URL that never reaches the disk cache (['ledger','receipt'] is persist-denied). Retention record type club_ledger_entry.

Payment reminders and the confirmation undo (00706) ​

  • payment_reminders is append-only, written only by remind_dues / remind_session_fee (authority is_club_admin, or the session host for a fee). Rules: the caller is never a recipient (self_remind_blocked); only a row that owes (dues_not_owed; a departed member owes nothing to a reminder); one reminder per target row per club-local day (a single id refuses reminder_sent_today, a batch skips and counts); the sender is rate-limited by the registry (payment_reminder, 60 per hour). A reminder is a signal (dues_reminder in category dues, session_fee_reminder in category payment) with a dated dedup key, so push follows the user's preferences and the daily caps.
  • unconfirm_dues_payment is the same-visit undo of a confirmation: the confirmer only, within ten minutes (undo_window_closed); it re-derives the dues status and drops the ledger income. money_evidence_immutable (00647) stays the rule for everything older.
  • unsettle_manual_session_payment (00716) is the session fee's twin: the recorder only, within ten minutes of paid_at, a manual paid row with no refund opened, back to pending with the payer's submitted_at kept (a waiver has its own Dialog and no undo). 참석자 관리 (L3-35) is where a session fee is confirmed, waived and undone; the old payments roster and its route are retired (W4 batch 9).

Markdown remains the source of truth. Run yarn docs:check before handoff.