정기결제도 사실 그냥 API로 제공해주는줄 알았는데, 알고보니 pg사 쪽에서는 정기결제에 사용할 빌링키만 제공하고 별도로 정기 결제가 이루어지도록 서버를 구축해야했다.
정기결제는 다음과 같은 프로세스를 통해 진행된다.
일단 유저가 프론트단에서 토스페이먼츠 API를 이용한 결제창을 띄워 카드 정보를 등록한다.
import { loadTossPayments } from '@tosspayments/payment-sdk'
const SubscriptionButton = () => {
const clientKey = 'test_ck_D5GePWvyJnrK0W0k6q8gLzN97Eoq'
const subscribe = () => {
loadTossPayments(clientKey).then(tossPayments => {
tossPayments.requestBillingAuth('카드', { // 결제 수단 파라미터
// 빌링키 발급 요청을 위한 파라미터
customerKey: '{고객을 구분할 수 있는 키}',
successUrl: 'http://localhost:3000/success',
failUrl: 'http://localhost:3000/fail',
})
.catch(function (error) {
if (error.code === 'USER_CANCEL') {
// 결제 고객이 결제창을 닫았을 때 에러 처리
} else if (error.code === 'INVALID_CARD_COMPANY') {
// 유효하지 않은 카드 코드에 대한 에러 처리
}
})
})
}
return <button onClick={subscribe}>구독하기</button>
}
export default SubscriptionButton
해당 버튼을 눌러 카드 정보를 올바르게 입력한 경우
https://{ORIGIN}/success?customerKey={고객을 구분할 수 있는 키}&authKey={authKey}
url로 리다이렉트된다.
그럼 해당 리다이렉트된 페이지에서 customerKey와 authKey를 받아 백단으로 전달해준다.
// SubscribeSuccessContainer.tsx
import { useRouter } from 'next/router'
const SubscribeSuccessContainer = () => {
const router = useRouter();
const { customerKey, authKey } = router.query;
useEffect(()=>{
// 백단으로 customerKey와 authKey를 넘겨주는 api 호출
}, [])
return (
<div>결제중입니다.</div>
)
}
이렇게 발급받은 customerKey와 authKey을 가지고 백단에서 billingKey를 발급받을 수 있다. billingKey는 결제를 하는데 이용하며, 그럼 이렇게 발급받은 billingKey를 저장해놨다가 주기적으로 결제하는데 이용하면 된다.
그 다음은 이렇게 받은 customerKey와 authKey를 가지고 어떻게 구현 & 데이터 관리를 할 것인가..를 생각해보았다.
일단 데이터를 관리하기 위한 DB부터 설계하였다.
구독을 하는 정기결제의 경우, 멤버십 종류가 다양하다. 베이직, 프리미엄 뭐 이런거일수도 있고, 월간, 연간일 수도 있다. 그 중 나는 월간/연간 이용권으로 나누었다.
이 때 멤버십별 가격과 해당 멤버십이 어느 주기마다 재결제가 이루어져야 하는지를 포함하고 있다.
class Plan(models.Model):
PLAN_CHOICES = (
("Monthly", "월간 이용권"),
("Yearly", "연간 이용권")
)
title = models.CharField('멤버십 명칭', max_length=10, unique=True, choices=PLAN_CHOICES)
price = models.IntegerField('가격')
month_duration = models.IntegerField('멤버십 월 주기', default=1)
created = models.DateTimeField(verbose_name='생성일시', auto_now_add=True)
updated = models.DateTimeField(verbose_name='수정일시', auto_now=True)
class Meta:
verbose_name = '멤버십 종류'
verbose_name_plural = '멤버십 종류'
def __str__(self):
return self.title
앞서 클라이언트에서 넘겨준 customerKey와 authKey를 통해 billingKey를 발급받으면 이를 저장하는 테이블이다. 재결제가 필요할 때마다 해당 테이블에서 빌링키를 불러온다.
class PaymentMethod(models.Model):
user = models.OneToOneField('user.User', on_delete=models.CASCADE, verbose_name='유저')
billing_key = models.CharField('빌링키', max_length=20, null=True, blank=True)
created = models.DateTimeField(verbose_name='생성일시', auto_now_add=True)
updated = models.DateTimeField(verbose_name='수정일시', auto_now=True)
class Meta:
verbose_name = '결제수단'
verbose_name_plural = '결제수단'
빌링키를 가지고 결제 요청을 한 경우 결제 내역들을 보관한다.
class Payment(models.Model):
PAYMENT_STATUS_CHOICES = (
("INITIATED", "INITIATED"), ("SUCCESS", "SUCCESS"),
("FAILED", "FAILED"), ("PENDING", "PENDING")
)
id = models.CharField('결제번호', primary_key=True, max_length=10)
user = models.ForeignKey('user.User', on_delete=models.CASCADE, verbose_name='유저')
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, verbose_name='멤버십 종류')
price = models.CharField('결제금액', max_length=10)
status = models.CharField('상태', max_length=10, choices=PAYMENT_STATUS_CHOICES, default="INITIATED")
response = models.TextField('응답메세지', null=True, blank=True)
created = models.DateTimeField(verbose_name='생성일시', auto_now_add=True)
updated = models.DateTimeField(verbose_name='수정일시', auto_now=True)
class Meta:
verbose_name = '결제내역'
verbose_name_plural = '결제내역'
결제가 끝나면 해당 유저의 멤버십을 기록한다.
class Subscription(models.Model):
user = models.OneToOneField('user.User', on_delete=models.CASCADE, verbose_name='유저')
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, verbose_name='멤버십 종류')
is_active = models.BooleanField('활성화 여부', default=False)
initiated_on = models.DateField('시작일', null=True, blank=True)
terminated_on = models.DateField('만료일', null=True, blank=True)
created = models.DateTimeField(verbose_name='생성일시', auto_now_add=True)
updated = models.DateTimeField(verbose_name='수정일시', auto_now=True)
class Meta:
verbose_name = '구독중인 멤버십'
verbose_name_plural = '구독중인 멤버십'
결제 취소 요청을 하는 경우
class Refund(models.Model):
payment = models.ForeignKey(Payment, on_delete=models.CASCADE, related_name='refund')
status = models.CharField('상태', max_length=1, choices=[('S', 'Success'), ('F', 'Fail')], null=True, blank=True)
response = models.TextField('응답메세지', null=True, blank=True)
created = models.DateTimeField(verbose_name='생성일시', auto_now_add=True)
class Meta:
verbose_name = '환불내역'
verbose_name_plural = '환불내역'
일단 클라이언트측에서 넘겨준 customerKey, authKey을 가지고 billingKey를 발급받고 이를 PaymentMethod에 저장한다.
관련된 코드는 serializer에서 작성하였다. 이후에 카드 변경을 하는 등 빌링키를 재발급 받을 경우를 생각하여 update_or_create를 사용하였다.
//views.py
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from api.membership.models import Payment, Plan
from api.membership.serializers import PaymentMethodSerializer
class PaymentMethodCreateAPIView(CreateAPIView):
queryset = PaymentMethod.objects.all()
serializer_class = PaymentMethodSerializer
// serializers.py
from rest_framework import serializers
from api.membership.models import Payment, PaymentMethod, Plan, Refund, Subscription
import requests
import json
class PaymentMethodSerializer(serializers.ModelSerializer):
auth_key = serializers.CharField(write_only=True)
customer_key = serializers.CharField(write_only=True)
class Meta:
model = PaymentMethod
fields = '__all__'
read_only_fields = (
'user',
)
def create(self, validated_data):
auth_key = validated_data['auth_key']
customer_key = validated_data['customer_key']
user = self.context['request'].user
data = {
"authKey": auth_key,
"customerKey":customer_key
}
headers = {
'Authorization': "Basic dGVzdF9za19aMFJuWVgydzUzMmUyMHE0MFF4OE5leXFBcFFFOg==",
'Content-Type': "application/json"
}
url = "https://api.tosspayments.com/v1/billing/authorizations/issue"
response = requests.post(url, data=json.dumps(data), headers=headers)
if response.status_code == 200:
billing_key = response.json()['billingKey']
payment_method, _ = PaymentMethod.objects.update_or_create(
user=user,
defaults={
'billing_key': billing_key
}
)
return payment_method
else:
raise Exception("빌링 발급에 실패했습니다.")
PaymentMethod를 create하고난 뒤에는 저장된 billingKey를 가지고 결제를 진행한다. 이러한 결제 진행상황은 Payment에 저장한다. 위의 PaymentMethodCreateAPIView 에서 이어진다. 모든 작업이 끝났을 때 클라이언트측 response는 Payment 데이터로 돌려준다.
orderId는 주문번호로, 나는 연월일에 랜덤한 영대문자 6개를 붙여 만들었다.
// views.py
class PaymentMethodCreateAPIView(CreateAPIView):
queryset = Payment.objects.all()
serializer_class = PaymentMethodSerializer
def create(self, request, *args, **kwargs):
try:
super().create(request, *args, **kwargs)
plan = Plan.objects.get(title=request.data['plan'])
payment = perform_payment(user=request.user, plan=plan)
serializer = PaymentSerializer(payment)
return Response(serializer.data)
except Exception as e:
return Response({'status': 'FAILED', 'reason': str(e)})
def perform_payment(user, plan):
price = plan.price
billing_key = PaymentMethod.objects.get(user=user).billing_key
id = datetime.today().strftime("%y%m%d")+"-"
for i in range(6):
id += str(random.choice(string.ascii_uppercase))
payment = Payment.objects.create(
id=id,
user=user,
plan=plan,
price=price,
)
data = {
"customerKey": user.email,
"amount": price,
"orderId": payment.id,
"orderName": "멤버십 구독",
"customerEmail": user.email,
"customerName": user.profile.name,
"taxFreeAmount": 0
}
headers = {
'Authorization': "Basic dGVzdF9za19aMFJuWVgydzUzMmUyMHE0MFF4OE5leXFBcFFFOg==",
'Content-Type': "application/json"
}
url = "https://api.tosspayments.com/v1/billing/{}".format(billing_key)
payment.status = 'PENDING'
payment.save()
response = requests.post(url, data=json.dumps(data), headers=headers).json()
if response['status'] == 'DONE':
payment.status = 'SUCCESS'
else:
payment.status = 'FAILED'
payment.response = response
payment.save()
return payment
결제가 성공하면 실제 구독 내역을 Subscription에 저장한다.
이는 signals에서 구현하였다. Payment가 save될때, 상태가 SUCCESS면 구독을 생성 or 업데이트한다.
//signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from api.membership.models import Payment, PaymentMethod, Subscription
import requests
import json
import random
import string
from datetime import datetime
from dateutil.relativedelta import relativedelta
@receiver(post_save, sender=Payment)
def create_subscription(sender, instance, created, **kwargs):
if instance.status == 'SUCCESS':
initiated_on = datetime.today()
terminated_on = initiated_on + relativedelta(months=instance.plan.month_duration)
Subscription.objects.update_or_create(
user=instance.user,
defaults={
'plan': instance.plan,
'is_active': True,
'initiated_on': datetime.today(),
'terminated_on': terminated_on,
}
)
이 과정의 핵심은 "정기결제" 이므로, 이후에 결제가 자동으로 이루어져야한다.
이를 위해 크론을 이용한다.
기존에 크론을 사용하고있지 않았다면 django-crontab을 설치하고 기본 셋팅을 해준다.
(참고 https://pypi.org/project/django-crontab/)
// cron.py
def perform_next_payment():
today = date.today()
subscription_list = Subscription.objects.filter(
is_active=True,
terminated_on__lte=today
)
for subscription in subscription_list:
perform_payment(subscription.user, subscription.plan)
매일 9시에 정기결제를 진행하고 금액 부족 등의 이유로 실패할 경우를 대비해 18시에 1회 더 진행한다.
settings.py
CRONJOBS = [
('0 9 * * *', 'api.membership.cron.perform_next_payment', '>> '+str(BASE_DIR)+'/cron.log'),
('0 18 * * *', 'api.membership.cron.perform_next_payment', '>> '+str(BASE_DIR)+'/cron.log')
]