Conventions › Session Fees & Dual-Rail Payments
Status: Active Last reviewed: 2026-08-03
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_totalis 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]/edituntil the session's FIRSTsession_paymentsrow exists —trg_block_fee_edit_with_holds(00404, HINT added 00649) is the server-side authority; the edit form mirrors it client-side viauseSessionPayments.
Reserve-then-pay backing model
- Fee state is backed by the existing
session_paymentstable in a reserve-then-pay pattern: confirming an RSVP on a fee session auto-creates apendinghold (private.sync_session_payment_on_rsvptrigger, 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_paymentRPC → setssubmitted_at,method='manual'); the host confirms receipt via the 정산 screen, flipping the row topaid. - 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 iscreated_byand usually not a club admin, so the 00059/00145 admin-only RLS + thecanManageDuesclient gates had locked pickup hosts out of confirming their own fee roster. 00303 adds additive permissivesession_paymentshost SELECT+UPDATE policies, and the 정산 entry + screen gates OR insession.createdBy === userId;markPaidstampsmethod='manual'. - 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 theconfirm_dues_paymentRPC carryuser_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 orcanManageDues, not the payer) viamarkWaived, 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). Defaultfalse= 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 viamarkWaived. - Authority for the flag is club-admin (the
clubs_updateRLS, 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 asdues_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 setparticipation_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
PaymentServicePortadapter (createMockPaymentService, OTA-safe pure JS) for the dev account (dev_mock_pay_session) — the real@portone/react-native-sdknative 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:
| State | Meaning |
|---|---|
'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) expiresLEAST(created_at + sessions.payment_hold_minutes (default 10), session_start)after reservation via the*/5cron (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.
Paid no-shows
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.
Dues bulk actions
U-15 (2026-09-17): the club dues roster's "전체 납부 확인" bulk mark-paid is one RPC (mark_dues_paid_bulk, migration 00648) — SECURITY INVOKER, RLS-authorised (the existing dues_update policy is the entire authorization surface, identical to the single-row mark-paid write).