PortOne v2 payments — setup guide
Status: Reference
In-app payment for session participation fees (and, later, dues), via PortOne v2 WebView (rule KR-3 — never per-PG SDKs directly). Runs alongside manual 계좌이체 tracking — both rails write the same session_payments hold.
Where it stands
| Piece | State |
|---|---|
| Hold model + 10-min from-reservation timer + manual 송금 완료 / 확인 대기 | Shipped (migrations 00298–00301; OTA) |
PaymentServicePort + mock adapter (debug, OTA-safe) | Shipped — 결제하기 works end-to-end for the dev account via dev_mock_pay_session |
mark_session_payment_paid_via_portone service RPC (00302) | Shipped + probe-verified (paid / idempotent / amount-mismatch) |
verify-portone-payment + portone-webhook edge functions | Scaffolded (this repo) — deploy + validate against a PortOne TEST channel |
@portone/react-native-sdk native adapter + registry swap | Pending the EAS build (native dep → new runtime) |
The whole gate is debuggable today with the mock (no PortOne, no native build). The steps below flip the real rail on.
1. PortOne console
- Create/confirm your Store → note the Store ID (
store-...). - Add a Channel (test first: a test PG like KG이니시스/토스페이먼츠 test) → note the Channel Key (
channel-key-...). - 결제연동 → V2 API secret → copy the API Secret.
- 웹훅(Webhook): set the Endpoint URL to the deployed
portone-webhookfunction (https://<project-ref>.supabase.co/functions/v1/portone-webhook) → copy the Webhook Secret (whsec_...). Enable at leastTransaction.Paid(+Transaction.VirtualAccountIssuedfor 가상계좌).
2. Supabase secrets + deploy the edge functions
supabase secrets set PORTONE_V2_API_SECRET=<v2 api secret>
supabase secrets set PORTONE_WEBHOOK_SECRET=whsec_<...>
# TWOMORE_SECRET_KEY (service-role) is already set.
supabase functions deploy verify-portone-payment # client calls with the user JWT
supabase functions deploy portone-webhook # verify_jwt = false comes from supabase/config.toml; PortOne calls, auth is the signatureBoth return 503 not_configured until the secrets are present — safe to deploy early.
3. Native adapter + EAS build (the last mile)
The RN SDK is a native dependency → a new EAS build (NOT OTA-able). Do this on a build commit, then bump app.json version so the new runtime is fingerprinted.
cd apps/mobile
npx expo install @portone/react-native-sdk react-native-webview
npm i -D @portone/browser-sdk
# app.json → "plugins": [..., "@portone/react-native-sdk/plugin"]Then add a real adapter mirroring packages/app/src/adapters/payment/mock-payment.service.ts:
createPortonePaymentService()opens the SDK<Payment request={{ storeId, channelKey, paymentId, orderName, totalAmount, currency:'CURRENCY_KRW', payMethod, customer, customData: JSON.stringify({ sessionId, userId }) }} />, resolves ononComplete/ rejects ononError.- Generate a unique
paymentIdper attempt (e.g.sp-${sessionId}-${userId}-${ts}). - For 가상계좌, set
payMethod:'VIRTUAL_ACCOUNT'+ avalidHoursequal to the hold window (10) — PortOne then fires theTransaction.Paidwebhook on deposit.
IMPORTANT (OTA safety): do NOT top-level-import @portone/react-native-sdk from a module that ships in an OTA bundle before the native build lands — a top-level requireNativeModule throws outside userland try/catch and crashes cold start (see the memory note). Keep the registry on the mock until the build ships, then swap registry.ts's payment: to the PortOne adapter behind a native guard.
4. Wire the client checkout to the real verify
Today useSessionPaymentCheckout calls sessionPayments.devMockConfirm after a mock "success". For the real rail, after the PortOne adapter resolves, call the verify-portone-payment function instead (server confirms with PortOne, then the 00302 RPC marks paid). Swap that one call; the hold/UI/state machine is unchanged.
5. The money flow + the 3% fee
Per the revenue model, participation fees route through PortOne with a 3% platform fee. Settlement to clubs (sub-merchant / marketplace) is a separate design — out of scope here; v1 can collect to one store and reconcile, or gate in-app pay to clubs once club-level settlement lands.
6. Paid payments the webhook cannot attribute
Every payment the app creates carries the customData { sessionId, userId }, and the webhook uses it to mark the right hold paid. When a Transaction.Paid payment has no usable binding (missing, or not JSON), the money has moved but no hold can be marked. The webhook records it in private.portone_unmatched_payments (migration 00700, through record_unmatched_portone_payment), logs [portone-webhook] paid payment <id> has no session binding (<reason>) and answers 200: a PortOne retry cannot repair the binding. If recording fails it answers 500, so PortOne retries. A paid payment is either marked, recorded or retried, never dropped (the owner, 2026-10-31).
To reconcile, read the table as the service role (or in the SQL editor), re-fetch each payment from PortOne by its id, then mark the right hold paid or refund the payment, and set reconciled_at. The table stores no customData; the payment id is enough to re-fetch everything.
Testing checklist (PortOne TEST channel)
- [ ]
verify-portone-paymentreturns{paid:true}for a TEST card payment; the hold flips topaid(참가 확정). - [ ] amount tampering (client sends a wrong amount) is rejected by the RPC
amount_mismatch. - [ ] 가상계좌: issue → the
portone-webhookmarks paid on the TEST deposit. - [ ] webhook signature failure → 401; stale timestamp → 400.
- [ ] a TEST payment created without customData is recorded in
private.portone_unmatched_paymentsand the webhook answers 200. - [ ] manual rail still works (송금 완료 → 확인 대기 → host confirm).