SW
포트원 결제 퀵 가이드
crossfit_wod
2025. 4. 28. 10:38
Front(브라우저)
1. 포트원 SDK 호출
2. 상품 정보 호출
결제 요청
문서 : https://developers.portone.io/opi/ko/quick-guide/payment?v=v2
- storeId: str => 포트원 계정에 생성된 상점을 식별하는 고유한 값
- channelKey: str => 채널키
- paymentId: str => 고객사 주문 고유 번호, 주문을 식별하는 고유번호로 직접 입력 필요. 이미 승인이 완료된 paymentId로 결제나 가상계좌 발급을 시도하는 경우 에러가 발생
- orderName: str => 주문명
- totalAmount: number => 결제 금액
- currentcy: str (KAKAO는 원화만 제공) => 결제통화
- payMethod: str (EASY_PAY) => 결제 수단
- customData: obj => 임의의 데이터를 저장할 수 있음. 서버에서 결제건 조회 시에 확인할 수 있으며, 상품 정보를 전달하여 서버가 인식한 상품 정보와 일치하는지 확인
- easyPay: obj
- useFreeInterestFromMall: boolean => 상점분담 무이자 활성화 여부
- useCardPoint: boolean => 카드사 포인트 사용 여부
- abailableCards: str[] => 카드사 일부 노출
- SHINHAN_CARD (신한카드)
- KOOKMIN_CARD (국민카드)
- HYUNDAI_CARD (현대카드)
- LOTTE_CARD (롯데카드)
- SAMSUNG_CARD (삼성카드)
- NH_CARD (NH농협카드)
- BC_CARD (BC카드)
- HANA_CARD (하나카드)
- CITI_CARD (씨티카드)
- KAKAO_BANK (카카오뱅크 카드)
- installment: obj => 할부 성정
- monthOption: obj => 할부 개월 수 설정
- fixedMonth: num => 구매자가 선택할 수 없도록 고정된 할부 개월 수 (KAKAO의 경우, 결제 금액이 5만원 미만이어도 해당 파라미터가 채워져 있으면 체크카드로 결제 불가능)
- monthOption: obj => 할부 개월 수 설정
- cashReceiptType: str => 결제창에서 발급 가능한 현금영수증 발급 유형
- PERSONAL (소득공제용)
- CORPORATE (지출증빙용)
- ANONYMOUS (미발행(국세청 번호 자동발급))
- customerIdentifier: str => 현금영수증 발행 대상 식별 정보
- availablePayMethods: str[] => 노출할 간편결제 방식
- CARD (카드결제)
- CHARGE (포인트 충전 및 적립 결제)
- MONEY (토스페이 머니 결제)
- TRASFER (계좌 결제)
- taxFreeAmout: num => 면세금액 (디폴트 0)
- vatAmount: num => 부가세 (디폴트 1/11 자동 계산)
- customer: obj => 구매자 정보
- customerId: str
- fullName: str
- firstName: str (firstName과 lastName은 함께)
- lastName: str (firstName과 lastName은 함께)
- phoneNumber: str
- email: str
- address: obj
- country: str
- addressLine1: str => 일반주소
- addressLine2: str => 상세주소
- city: str
- province: str
- zipcode: str
- gender: str
- MALE
- FEMALE
- OTHER
- birthYear: str => 1990
- birthMonth: str => 07
- birthDay: str => 01
- bypass: obj => 결제 대행사 고유 기능
- kakao: obj
- custom_message: str => 결제 화면에 보여줄 사용자 정의 문구 (KAKAO 와 사전 협의 필요)
- kakao: obj
- redirectUrl: str => 결제 프로세스 완료 후 이동될 고객사 URL (모바일 환경에서는 필수)
- noticeUrls: str[] => 결제 웹훅 수신 URL (관리자 콘솔에서 설정한 웹훅 주소 대신 사용할 주소)
- appScheme: str => 모바일 결제 후 고객사 앱으로 복귀하기 위한 URL scheme (KAKAO는 iOS만 가능)
- productType: str => 상품유형 (휴대폰 소액결제 시 productType는 필수 입력이며 상점에 설정된 상품 유형과 입력된 상품 유형이 다른 경우 결제가 실패)
- PRODUCT_TYPE_REAL: 실물상품
- PRODUCT_TYPE_DIGITAL: 디지털 상품
- offerPeriod: oneof obj => 서비스 제공 기간
- range: obj => 서비스 제공 기간 범위
- from: str => 시작시점
- to: str => 종료시점
- interval: str => 제공기간
- {number}d
- {number}m
- {number}y
- range: obj => 서비스 제공 기간 범위
- products: obj[] => 구매 상품 상세 정보
- id: str => 상품 아이디
- name: str => 상품명
- code: str => 상품코드
- amount: num => 상품 단위 가격
- quantity: num => 상품 수량
- tag: str => 상품 태그
- link: str => 생품 링
- storeDetails: obj
- ceoFullName: str => 상점 대표자 이름
- phoneNumber: str => 상점 연락처
- address: str => 상점주소
- zipcode: str => 상점 우편번호
- businessName: str => 상점 사업자명
- businessRegistrationNumber: str => 상점 사업자 번호
- isCulturalExpense: obj => 문화비 지출 여부 (도서, 공연, 박물관 등 문화비 지출 여부, KAKAO는 문화비 지원 X)
- isEscrow: boolean => 에스크로 결제 여부 (KAKAO는 에스크로 결제를 지원하지 않음)
- country: str => 결제 국가
- promotionId: str => 프로모션 ID
- popup: obj => 결제창이 팝업 방식일 경우 결제창에 적용할 속성
- center: boolean
결제 오류 처리
SDK의 반환 값에 code가 있는 경우 오류 상태로 message 필드에 오류 메세지가 존재. 결제대행사로부터 오류를 전달받은 경우 code는 FAILURE_TYPE_PG이고, 결제대행사의 오류 코드인 pgCode를 기반으로 결제 오류를 처리할 수 있습니다.
서버 측으로 결제 완료 요청
완료된 결제의 paymentId를 서버로 전송하여 결제 상태를 반영
- 결제 완료 상태 처리
- 결제 실패 상태 처리
Back
import json
import os
from dataclasses import dataclass
from typing import Annotated
import portone_server_sdk as portone
from fastapi import Body, Depends, FastAPI, Request
@dataclass
class Item:
id: str
name: str
price: int
currency: portone.common.Currency
@dataclass
class Payment:
status: str
app = FastAPI()
items = {item.id: item for item in [Item("shoes", "신발", 1000, "KRW")]}
portone_client = portone.PaymentClient(secret=os.environ["V2_API_SECRET"])
@app.post("/api/payment/complete")
def complete_payment(payment_id: Annotated[str, Body(embed=True, alias="paymentId")]):
payment = sync_payment(payment_id)
if payment is None:
return "결제 동기화에 실패했습니다.", 400
return payment
payment_store = {}
def sync_payment(payment_id):
if payment_id not in payment_store:
payment_store[payment_id] = Payment("PENDING")
payment = payment_store[payment_id]
try:
actual_payment = portone_client.get_payment(payment_id=payment_id)
except portone.payment.GetPaymentError:
return None
if isinstance(actual_payment, portone.payment.PaidPayment):
if not verify_payment(actual_payment):
return None
if payment.status == "PAID":
return payment
payment.status = "PAID"
print("결제 성공", actual_payment)
else:
return None
return payment
def verify_payment(payment):
if payment.custom_data is None:
return False
custom_data = json.loads(payment.custom_data)
if "item" not in custom_data or custom_data["item"] not in items:
return False
item = items[custom_data["item"]]
return (
payment.order_name == item.name
and payment.amount.total == item.price
and payment.currency == item.currency
)
@app.get("/api/item")
def get_item():
return items["shoes"]
async def get_raw_body(request: Request):
return await request.body()
@app.post("/api/payment/webhook")
def receive_webhook(request: Request, body=Depends(get_raw_body)):
try:
webhook = portone.webhook.verify(
os.environ["V2_WEBHOOK_SECRET"],
body.decode("utf-8"),
request.headers,
)
except portone.webhook.WebhookVerificationError:
return "Bad Request", 400
if not isinstance(webhook, dict) and isinstance(
webhook.data, portone.webhook.WebhookTransactionData
):
sync_payment(webhook.data.payment_id)
return "OK", 200
1. 포트원 서버 SDK 호출
# uv
uv add portone-server-sdk
# pipenv
pipenv install portone-server-sdk
# pip requirements
portone-server-sdk ~= x.x.x
2. 포트원 API Secret 설정
서버 SDK를 사용하기 위해 포트원 V2 API Secret을 설정
결제연동>연동정보>식별코드.API Keys>V2 API
portone_client = portone.PaymentClient(secret=os.environ["V2_API_SECRET"])
3. 결제 완료 요청
완료된 결제의 실제 상태를 조회 후 시스템에 반영, 브라우저 SDK를 통해 결제되는 경우 모든 결제 과정이 브라우저에서 진행되므로 결제가 조작되는 것을 막기 위해 서버에서 검증이 필요
@app.post("/api/payment/complete")
def complete_payment(payment_id: Annotated[str, Body(embed=True, alias="paymentId")]):
payment = sync_payment(payment_id)
if payment is None:
return "결제 동기화에 실패했습니다.", 400
return payment
4. 결제정보조회
브라우저에서 전송한 paymentId를 통해 실제 결제 상태를 조회
try:
actual_payment = portone_client.get_payment(payment_id=payment_id)
except portone.payment.GetPaymentError:
return None
5. 결제 정보 일치 검증
포트원에서 전달한 customData로 조회한 상품 정보와 결제 정보가 일치하는지 검증
def verify_payment(payment):
if payment.custom_data is None:
return False
custom_data = json.loads(payment.custom_data)
if "item" not in custom_data or custom_data["item"] not in items:
return False
item = items[custom_data["item"]]
return (
payment.order_name == item.name
and payment.amount.total == item.price
and payment.currency == item.currency
)
6. 웹훅 수신
결제 상태의 변화를 실시간으로 확인하기 위해 웹훅 사용
- HTTP Body 수신 설정 : 웹훅 내용을 검증하기 위해서 HTTP Body를 문자열 형태로 수신
async def get_raw_body(request: Request):
return await request.body()
- 웹훅 검증 : 수신한 웹훅이 위조되지 않았는지 포트원 서버 SDK를 사용하여 검증
try:
webhook = portone.webhook.verify(
os.environ["V2_WEBHOOK_SECRET"],
body.decode("utf-8"),
request.headers,
)
except portone.webhook.WebhookVerificationError:
return "Bad Request", 400
- 결제 상태 업데이트 : 검증된 웹훅 결과를 바탕으로 결제 상태를 업데이트
if not isinstance(webhook, dict) and isinstance(
webhook.data, portone.webhook.WebhookTransactionData
):
sync_payment(webhook.data.payment_id)
포트원 Payment 객체
PaidPayment(
id='48bbbe86bae0046d', # 결제 ID
transaction_id='01968581-8623-fc6ef1bc2d4', # 트랜잭션 ID
merchant_id='merchant-43d45cd8-fa9-870a-5ac1e639ca1b', # 상점 ID
store_id='store-3c72db52-7cacbbc399b8d666', # 매장 ID
channel=SelectedChannel(
type='TEST', # 테스트 환경
pg_provider='KAKAOPAY', # PG사
pg_merchant_id='TC0ONETIME',
id='channel-id-af9f889d-8e1f-4a15b83d',
key='channel-key-7e7dc4f1-af32e22b2a3d',
name='test_kakaopay'
),
version='V2',
requested_at='2025-04-30T07:03:00.425478069Z',
updated_at='2025-04-30T07:03:13.710463751Z',
status_changed_at='2025-04-30T07:03:13.710463751Z',
order_name='코인 10개',
amount=PaymentAmount(
total=1000,
tax_free=0,
discount=0,
paid=1000,
cancelled=0,
cancelled_tax_free=0,
vat=91,
supply=909
),
currency='KRW',
customer=Customer(
id='2dcb8268-04dd-485975ee7',
name=None,
birth_year=None,
gender=None,
email='raonjhc@gmail.com',
phone_number=None,
address=None,
zipcode=None
),
paid_at='2025-04-30T07:03:13.710463751Z',
disputes=[],
method=PaymentMethodEasyPay(
provider='KAKAOPAY',
easy_pay_method=PaymentMethodEasyPayMethodCharge(bank=None)
),
custom_data='{"item":"c8e36144-8c18e48f6f8846"}', # ✅ 상품 ID 등
country=None,
pg_tx_id='T811cb244238',
pg_response='''{
"aid":"A811ca744239",
"tid":"T811a744238",
"cid":"TC0ONETIME",
"partner_order_id":"48bbbee0046d",
"partner_user_id":"2dcb8268-0485e-8fb485975ee7",
"payment_method_type":"MONEY",
"item_name":"코인 10개",
"quantity":1,
"amount":{
"total":1000,
"tax_free":0,
"vat":91,
"point":0,
"discount":0,
"green_deposit":0
},
"created_at":"2025-04-30T16:03:01",
"approved_at":"2025-04-30T16:03:13"
}''',
receipt_url='https://mockup-pg-web.kakao.com/v1/confirmation/p/T811cb24308b2a744238/969FEE3420BA2D582EE948A0DD2A17C4013400F16F63124F0846ABF9F556BF2E',
products=[
PaymentProduct(
id='c8e36144-8c1f6f8846',
name='코인 10개',
amount=1000,
quantity=1,
tag=None,
code=None,
link=None
)
],
webhooks=[
PaymentWebhook(
id='0196858894166352',
url='https://oneteacher-ai.site/public/payment/webhook',
payment_status='READY',
status='FAILED_NOT_OK_RESPONSE', # ❌ 응답이 200이 아님
is_async=True,
current_execution_count=1,
request=PaymentWebhookRequest(
body='{"type":"Transaction.Ready","timestamp":"2025-04-30T07:03:00.625476840Z","data":{"transactionId":"01968581-8623-fc6e-8c1e-ca851f1bc2d4","paymentId":"48bbbe86bae0046d","storeId":"store-3c72db52-7cac-4e94-bf1d-bbc399b8d666"}}',
header='''webhook-id: msg-01968581-8736-5ad1-411a-1d7894166352
Content-Length: 223
webhook-signature: v1,1BS1BliWHdfjD2N0JrgoEjWN7Yzb2p/Ym0N/hG5YQzs=
webhook-timestamp: 1745996580
Content-Type: application/json
Accept-Encoding: gzip, deflate''',
requested_at='2025-04-30T07:03:00.662282459Z'
),
response=PaymentWebhookResponse(
code='400',
header='''date: Wed, 30 Apr 2025 07:03:00 GMT
server: uvicorn
content-length: 44
content-type: application/json''',
body='{"detail":"❌ Webhook verification failed"}',
responded_at='2025-04-30T07:03:01.022806877Z'
),
triggered_at='2025-04-30T07:03:00.662276658Z'
)
],
promotion_id='',
is_cultural_expense=False,
escrow=None,
billing_key=None,
schedule_id=None,
product_count=None,
cash_receipt=None,
channel_group=None
)