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만원 미만이어도 해당 파라미터가 채워져 있으면 체크카드로 결제 불가능)
    • 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 와 사전 협의 필요)
  • 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
  • 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 필드에 오류 메세지가 존재. 결제대행사로부터 오류를 전달받은 경우 codeFAILURE_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
)