LoginSignup
19
16

More than 1 year has passed since last update.

FastAPI × Stripe サブスクリプション登録を試す

Last updated at Posted at 2021-08-01

はじめに

サブスクリプション課金を行う際、Stripeを利用すると比較的簡単に課金が行えますが、DB上にある顧客の情報を使いたかったり、すでにサブスクリプション課金が行われている顧客に再度登録が走らないようにしたいなどの理由で、バックエンド側のAPIサーバーを絡めるケースはあるかと思います。

Stripeでサブスクリプション登録をするためには、以下3つの作業が必要になります。
1. Planを登録する
2. Customerを登録する
3. Subscriptionを登録する

これら3つについて、FastAPI経由での実装を試してみました。

事前準備

FastAPI

FastAPI公式に従いアプリケーションの準備を行います。

Stripe

Stripe公式にてアカウント登録を行います。

登録を行うと、テスト環境が利用可能となります。
また、ダッシュボードの「商品」タブから商品登録を行います。この際料金情報の登録は不要です。

Planを登録する

以下のような形で実装しました。

main.py
import stripe
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException

app = FastAPI()

STRIPE_SECRET_KEY = "[Stripeダッシュボードから取得できるシークレットキー]"

class Plan(BaseModel):
    amount: int
    interval: str
    product: str
    nickname: Optional[str] = None


@app.post("/plans/")
async def create_plan(plan: Plan):
    stripe.api_key = STRIPE_SECRET_KEY
    try:
        result = stripe.Plan.create(
            amount=plan.amount,     # 1単位当たりの金額
            currency="jpy",         # 通貨単位(今回はJPY固定)
            interval=plan.interval, # 支払の周期。1ヶ月毎なら"month"など
            product=plan.product,   # 商品のID
            nickname=plan.nickname  # プランの名前
            )
    except Exception:
        message = "StripeのPlan登録に失敗しました"
        return message

    return f'StripeのPlan登録に成功しました。ID:{result.get("id")}'

※本来は別ファイルに分けるべき箇所もありますが、お試しなので同一ファイルに記載しています

これで、Bodyに以下のような内容を詰め込んだPOSTリクエストを投げることで、「月額1000円」という名前の、月に1単位あたり1000円請求するPlanが作成されます。

{
    "amount": 1000,
    "interval": "month",
    "product": "[商品のID]",
    "nickname": "月額1000円"
}

Customerを登録する

Customerもほぼ同様ですが、ちょっとDBを絡めています。
今回はFastAPIのチュートリアルに記載のある通りにDB(SQLite)を繋いでいます。

id,email,name,stripe_customer_id,stripe_subscription_id の5カラムを持つCustomerテーブルがあることを前提としています。

main.py
<前略>

class StripeCustomer(BaseModel):
    email: str


@app.post("/customers/")
async def create_customer(customer: StripeCustomer, db: Session = Depends(get_db)):

    # DB内のCustomer検索
    db_customer = crud.get_customer_by_email(db, email=customer.email)
    if not db_customer:
        raise HTTPException(status_code=400, detail="Customerが存在しません")
    if db_customer.stripe_customer_id:
        raise HTTPException(status_code=400, detail="すでにStripeにCustomerとして登録されています")

    # Stripeへの登録
    try:
        stripe.api_key = STRIPE_SECRET_KEY
        result = stripe.Customer.create(
            name=db_customer.name,
            email=db_customer.email
            )
    except Exception:
        message = "StripeのCustomer登録に失敗しました"
    return message

    # StripeのCustomerIDをDBに反映
    update_customer = schemas.UpdateCustomer(id=db_customer.id,name=db_customer.name,email=db_customer.email,stripe_customer_id=result.get('id'))

    return crud.update_customer(db=db, customer=update_customer)

これで、Bodyに以下のような内容を詰め込んだPOSTリクエストを投げることで、
・Customerテーブルに存在する
・Stripe側に登録されていない
CustomerをStripeのCustomerに登録可能となります。

{
    "email": "[Customerテーブルに存在するメールアドレス]"
}

Subscriptionを登録する

Customeとほぼ同様です。

main.py
<前略>

class Subscription(BaseModel):
    customer_email: str
    plan: str
    quantity: int


@app.post("/subscriptions/")
async def create_subscription(subscription: schemas.Subscription, db: Session = Depends(get_db)):
    # DB内のCustomer検索
    db_customer = crud.get_customer_by_email(db, email=subscription.customer_email)
    if not db_customer:
        raise HTTPException(status_code=400, detail="Customerが存在しません")
    if db_customer.stripe_subscription_id:
        raise HTTPException(status_code=400, detail="すでにSubscriptionが登録されています")

    # Stripeへの登録
    stripe.api_key = STRIPE_SECRET_KEY
    try:
        result = stripe.Subscription.create(
            customer=db_customer.stripe_customer_id,
            items=[{"plan":subscription.plan, "quantity":subscription.user_count}],     # 対象PlanとPlan数
            collection_method="send_invoice",   # 即時決済 or 請求書送付。今回は請求書送付固定
            days_until_due=30   # 支払までの日数。今回は30日固定。
            )

    except Exception:
        message = "StripeのSubscription登録に失敗しました"
        return message

    # StripeのSubscription IDをDBに反映
    update_customer = schemas.UpdateCustomer(id=db_customer.id, name=db_customer.name,email=db_customer.email, stripe_subscription_id=result.get('id'))

    return crud.update_customer(db=db, customer=update_customer)

これで、Bodyに以下のような内容を詰め込んだPOSTリクエストを投げることで、
・Customerテーブルに存在する
・Stripe側にサブスクリプションが登録されていない
場合、そのCustomerに対するサブスクリプションをStripeに登録可能となります。

{
    "customer_email": "[Customerのメールアドレス]",
    "plan": "[PlanのID]",
    "quantity":[数量]
}

が、これだけで登録すると、ちょっと問題があるので、次で問題点の対応をしていきます。

Subscription登録の修正

現在は、Subscription登録時に指定している内容は以下のとおりです。

main.py
result = stripe.Subscription.create(
        customer=db_customer.stripe_customer_id,
        items=[{"plan":subscription.plan, "quantity":subscription.user_count}],
        collection_method="send_invoice",
        days_until_due=30
        )

指定項目がこれだけだと、登録日を基準として毎月の請求が発生してしまいます。
例)2021/08/02 15:30に、1ヶ月毎のPlanでSubscriptionを登録した場合
請求1回目:2021/08/02 15:30
請求2回目:2021/09/02 15:30
請求3回目:2021/10/02 15:30
・・・

そのため、例えば毎月15日に請求を行いたい場合、15日にサブスクリプション登録を行う必要がありますが、その運用はかなり負荷が大きい(休日に作業する羽目になったり…など)です。

そこで設定できる内容に、billing_cycle_anchorがあります。
これはサブスクリプションの請求サイクル日を決め打ちできる項目で、unix時間で設定の必要がありますが、とても便利です。

ただ、今度は登録日と次の請求サイクル日の間の請求をどうするか(日割で課金するか、お金をとらないのか)の問題が発生しますが、これもproration_behaviorという項目でコントロール可能です。
create_prorationsを指定することで日割課金、noneを指定することでお金を取らず課金発生は請求1回目から、という制御ができます。

さらに、上記proration_behaviorで日割で課金する場合、Stripeが日割での金額は自動計算してくれるものの、秒単位での日割計算を行うため、登録時刻により日割請求分の金額に誤差が発生します。
この誤差を矯正するためには、backdate_startdateという項目を設定します。
これはサブスクリプション開始日時を決め打ちできる項目です。こちらもunix時間で設定します。

請求開始日固定、日割請求を行う場合は以下のような形になるかと思います。

main.py
<前略>

class Subscription(BaseModel):
    customer_email: str
    plan: str
    quantity: int
    billing_cycle_anchor: str
    backdate_start_date: str


@app.post("/subscriptions/")
async def create_subscription(subscription: schemas.Subscription, db: Session = Depends(get_db)):
    # DB内のCustomer検索
    db_customer = crud.get_customer_by_email(db, email=subscription.customer_email)
    if not db_customer:
        raise HTTPException(status_code=400, detail="Customerが存在しません")
    if db_customer.stripe_subscription_id:
        raise HTTPException(status_code=400, detail="すでにSubscriptionが登録されています")

    # Stripeへの登録
    stripe.api_key = STRIPE_SECRET_KEY
    try:
        result = stripe.Subscription.create(
            customer=db_customer.stripe_customer_id,
            items=[{"plan":subscription.plan, "quantity":subscription.user_count}],
            collection_method="send_invoice",
            days_until_due=30,
            billing_cycle_anchor=convert_to_unixtime(subscription.billing_cycle_anchor),
            proration_behavior="create_prorations",
            backdate_start_date=convert_to_unixtime(subscription.backdate_start_date)
            )

    except Exception:
        message = "StripeのSubscription登録に失敗しました"
        return message

    # StripeのSubscription IDをDBに反映
    update_customer = schemas.UpdateCustomer(id=db_customer.id, name=db_customer.name,email=db_customer.email, stripe_subscription_id=result.get('id'))

    return crud.update_customer(db=db, customer=update_customer)

def convert_to_unixtime(dt: str):
    unixtime = int(datetime.strptime(dt, "%Y-%m-%d %H:%M:%S %z").timestamp())
    return unixtime

この場合は、Bodyに以下のような内容を詰め込んだPOSTリクエストを投げることで
・日割請求期間:2021/08/01 15:00〜2021/08/15 15:00(14日分)
・日割請求額:Plan金額 × 数量 × 14/31
・(満額での)初回請求日:2021/08/15 15:00
・請求2回目:2021/09/15 15:00
という形になります。

{
    "customer_email": "[Customerのメールアドレス]",
    "plan": "[PlanのID]",
    "quantity":[数量]
    "billing_cycle_anchor":"2021-08-15 15:00:00 +0900",
    "backdate_start_date":"2021-08-01 15:00:00 +0900"
}

さいごに

もともと弊社(株式会社hokan)ではDjango × Stripeで決済周りは行っているのですが、せっかくなのでFastAPI × Stripeを試してみました。
どちらかというとStripeの仕様の理解が大変なように思います(日割計算が秒単位とは…!)

参考

FastAPI公式
Stripe APIリファレンス
Stripe Docs 請求サイクル
Stripe Docs 日割り計算
Stripe Billing 101

19
16
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
16