Payments — Integration
Quick summary
Payments connect checkout, confirmation, license issuing, refunds, and ledger records. The current integration is mock or Toss TEST, not verified live capture.
Choose a provider path, test a purchase and refund, then consult the reconciliation guide for evidence and known gaps.
No real money moves in this wave
PAYMENT_PROVIDER is mock or toss (TEST keys only), PAYMENT_LIVE_ENABLED=false in wrangler.toml, and Portone is unimplemented (503 PAYMENT_PROVIDER_UNAVAILABLE). A real PG sandbox roundtrip is BLOCKED_EXTERNAL_CREDENTIALS (limitations §2.1). Everything below describes the implemented flow honestly — do not present it as production capture.
Checkout flow
mock provider (default dev path)
POST /api/v1/checkout (public)
{ "product_id": "…", "buyer_email": "…", "idempotency_key": "…", … }2
One atomic D1 batch writes: order + payment + subscription + license (NG-… key minted in the same batch) + ledger + audit + outbox event. idempotency_key dedupes — a retry returns the same order rather than a second charge.
toss provider (TEST keys)
POST /checkout→pendingorder + Toss widget payload (toss_impl::checkout_toss).- Buyer completes the widget →
POST /checkout/confirm→finalize_paid(same side-effects as mock paid). POST /webhooks/toss— signature-verified (TOSS webhook secret), ref-validated, PG state is refetched — the webhook body is never trusted as authority.
Live env + mock → 402 NOT_LIVE_PAYMENT — the refuse-loudly path, not a silent fallback.
Refunds
POST /api/v1/orders/:id/refund (seller-owner)
GET /api/v1/orders/:id/refund-attempts (seller-owner audit view)2
The refund path is attempt-first:
- Pre-record
refund_attempts(status='attempted')— the attempt exists even if the process dies mid-flight. - Re-check remaining refundable amount (partial refunds accumulate against
amount_krw; cannot exceed paid). - PG cancel (Toss
cancelReason/cancelAmount) or mock path. - Outcomes:
succeeded→ ledger + license compensation;failed→ attempt markedfailedwitherror_json; uncertain PG result →needs_review— manual ops state, never auto-marked refunded. already_processedshort-circuit: re-refunding an order whose attempts already cover the full amount returns{already_processed:true}.
There is no needs_review sweeper — those attempts wait for an operator (limitations §4.15).
Donations (separate spine)
Public donations POST to /public/integrations/{public_id}/donations and land in the donations table with status:"sandbox" — structurally separate from orders/payments/ledger by schema design. See SDK scope.
Reading payments
| Endpoint | Auth |
|---|---|
GET /orders, GET /orders/:id | seller (own rows) / admin |
GET /payments/:id | seller/admin |
GET /public/orders/:order_no?email= | public buyer receipt lookup (order_no + email) |
POST /purchase/game-item | user |
POST /fulfillments/:id/retry | seller |
Buyer note: there is no buyer self-service dashboard — email is not a credential (limitations §3.3).