この記事は GMOインターネットグループ Advent Calendar 202416日目の記事です。GMO NIKKOの野田です。現在GMO NIKKOでは新規プロダクトの開発を行っており、fincode byGMOでプラン別の決済機能を実装しました。今回はfincode byGMOの使用例として参考にしていただけるように記事を書きました。
fincode byGMOとは
GMOイプシロン株式会社が提供する決済サービスです。https://www.fincode.jp/
「エンジニアファーストの設計、洗練されたUXで最高の開発体験を」という言葉通り、エンジニアにとって使いやすく、ドキュメントも充実しています。https://docs.fincode.jp/GMOインターネットグループの決済サービスには、GMOペイメントゲートウェイ株式会社が提供する、PGマルチペイメントサービスという業界を代表するサービスもあります。https://www.gmo-pg.com/service/mulpay/
PGマルチペイメントサービスの使用も検討しましたが、fincode byGMOはスタートアップ向けということで、新規プロダクトの要件にも合っていたので、今回はfincode byGMOを使うことになりました。
fincode byGMOのテスト環境について
fincode byGMOにはテスト環境が用意されています。https://docs.fincode.jp/tutorial/test_sign_upこちらはfincode byGMOの申し込み前からアカウントを作成できるので、事前にAPIの検証を行うことができます。テストで使用できるカードも用意されているので、決済のテストも行うことができます。https://docs.fincode.jp/develop_support/test_resources
プラン別決済機能の実装
プラン別決済機能とは
プラン別の決済機能は、以下のようなイメージです。
ユーザーが選択したプランに応じて、毎月、毎年などの期間ごとに設定された金額で決済を行います
事前準備
プラン別決済機能の実装のためには、以下のデータが必要になります。
顧客カード(カード決済の場合)プランサブスクリプション
プラン別決済機能で使用するプランのデータを作成し、それをサブスクリプション機能で顧客に割り当てるイメージです。今回の記事では、サブスクリプションをAPI経由で作成する方法について記載し、その他のデータはfincode byGMOの管理画面から作成しました。管理画面からの作成方法は、公式のブログが参考になります。https://blog.fincode.jp/product_blog/376-2/
サブスクリプションの作成
ユーザーがサービスの申し込みをした時に、サブスクリプションというデータを作成し、ユーザーにプランを割り当てます。
(画像はテスト環境のものです。)サブスクリプションについては以下に説明が記載されています。https://docs.fincode.jp/payment/subscriptionfincode byGMOには決済APIもあり、そちらで決済処理を行うこともできますが、サブスクリプションを作成することで定期的な決済処理を行うことができます。サブスクリプションには、課金開始日や初回利用金額を設定できるので、無料期間の設定や、課金日を月初や月末にする場合に、申込日に応じた日割り課金の設定も行うことができます。サブスクリプションは実際の運用でもAPI経由で作成するので、今回はいくつかのパターンでサブスクリプションをAPI経由で登録するソースコードを作成しました。ちなみに、今回記載した例は、実際のプロダクトの仕様とは異なる場合があります。(今回は消費税の計算については省略しています。)
サブスクリプションを作成する処理
サブスクリプションの登録、解約、取得を行う処理を作成しました。こちらを使用して各パターンの決済処理を行います。
package fincode
import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
)
type FincodeClient struct {
	apiKey     string
	apiUrl     string
	httpClient *http.Client
}
func NewFincodeClient() *FincodeClient {
	return &FincodeClient{
		apiKey:     "fincode byGMOの管理画面から取得したkey",
		apiUrl:     "https://api.test.fincode.jp",
		httpClient: &http.Client{},
	}
}
func (c *FincodeClient) CreateSubscription(request *CreateSubscriptionRequest) (*CreateSubscriptionResponse, error) {
	requestBody, err := json.Marshal(request)
	if err != nil {
		return nil, err
	}
	// リクエストの作成
	req, err := http.NewRequest("POST", fmt.Sprintf("%s/v1/subscriptions", c.apiUrl), bytes.NewBuffer(requestBody))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
	req.Header.Set("Content-Type", "application/json")
	// リクエストの送信
	res, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Fatal(err)
		}
	}(res.Body)
	responseBody, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatalf("Failed to read requestBody: %v", err)
	}
	var response CreateSubscriptionResponse
	err = json.Unmarshal(responseBody, &response)
	if err != nil {
		return nil, err
	}
	return &response, nil
}
func (c *FincodeClient) DeleteSubscription(subscriptionID string) (*CreateSubscriptionResponse, error) {
	// リクエストの作成
	params := url.Values{}
	params.Add("pay_type", "Card")
	
	req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/subscriptions/%s?%s", c.apiUrl, subscriptionID, params.Encode()), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
	// リクエストの送信
	res, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Fatal(err)
		}
	}(res.Body)
	responseBody, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatalf("Failed to read responseBody: %v", err)
	}
	var response CreateSubscriptionResponse
	err = json.Unmarshal(responseBody, &response)
	if err != nil {
		return nil, err
	}
	return &response, nil
}
func (c *FincodeClient) GetSubscription(subscriptionID string) (*GetSubscriptionResponse, error) {
	// リクエストの作成
	params := url.Values{}
	params.Add("pay_type", "Card")
	req, err := http.NewRequest("GET", fmt.Sprintf("%s/v1/subscriptions/%s?%s", c.apiUrl, subscriptionID, params.Encode()), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
	// リクエストの送信
	res, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Fatal(err)
		}
	}(res.Body)
	responseBody, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatalf("Failed to read responseBody: %v", err)
	}
	var response GetSubscriptionResponse
	err = json.Unmarshal(responseBody, &response)
	if err != nil {
		return nil, err
	}
	return &response, nil
}
type PayType string
const (
	PayTypeCard PayType = "Card"
	PayTypeBank PayType = "Bank"
)
type CreateSubscriptionRequest struct {
	ID              *string `json:"id,omitempty"`
	PayType         PayType `json:"pay_type"`
	PlanID          string  `json:"plan_id"`
	CustomerID      string  `json:"customer_id"`
	CardID          *string `json:"card_id,omitempty"`
	PaymentMethodID *string `json:"payment_method_id,omitempty"`
	StartDate       *string `json:"start_date,omitempty"`
	StopDate        *string `json:"stop_date,omitempty"`
	EndMonthFlag    *string `json:"end_month_flag,omitempty"`
	InitialAmount   *string `json:"initial_amount,omitempty"`
	InitialTax      *string `json:"initial_tax,omitempty"`
	Remarks         *string `json:"remarks,omitempty"`
	ClientField1    *string `json:"client_field_1,omitempty"`
	ClientField2    *string `json:"client_field_2,omitempty"`
	ClientField3    *string `json:"client_field_3,omitempty"`
}
type CreateSubscriptionResponse struct {
	ID                 string  `json:"id"`
	ShopID             string  `json:"shop_id"`
	PayType            PayType `json:"pay_type"`
	PlanID             string  `json:"plan_id"`
	PlanName           string  `json:"plan_name"`
	CustomerID         string  `json:"customer_id"`
	CardId             string  `json:"card_id"`
	PaymentMethodId    string  `json:"payment_method_id"`
	Amount             int     `json:"amount"`
	Tax                int     `json:"tax"`
	TotalAmount        int     `json:"total_amount"`
	InitialAmount      int     `json:"initial_amount"`
	InitialTax         int     `json:"initial_tax"`
	InitialTotalAmount int     `json:"initial_total_amount"`
	Status             string  `json:"status"`
	StartDate          string  `json:"start_date"`
	NextChargeDate     string  `json:"next_charge_date"`
	StopDate           string  `json:"stop_date"`
	EndMonthFlag       string  `json:"end_month_flag"`
	ErrorCode          string  `json:"error_code"`
	ClientField1       string  `json:"client_field_1"`
	ClientField2       string  `json:"client_field_2"`
	ClientField3       string  `json:"client_field_3"`
	Remarks            string  `json:"remarks"`
	Created            string  `json:"created"`
	Updated            string  `json:"updated"`
}
type DeleteSubscriptionResponse struct {
	ID                 string  `json:"id"`
	ShopID             string  `json:"shop_id"`
	PayType            PayType `json:"pay_type"`
	PlanID             string  `json:"plan_id"`
	PlanName           string  `json:"plan_name"`
	CustomerID         string  `json:"customer_id"`
	CardID             string  `json:"card_id"`
	PaymentMethodID    string  `json:"payment_method_id"`
	Amount             int     `json:"amount"`
	Tax                int     `json:"tax"`
	TotalAmount        int     `json:"total_amount"`
	InitialAmount      int     `json:"initial_amount"`
	InitialTax         int     `json:"initial_tax"`
	InitialTotalAmount int     `json:"initial_total_amount"`
	Status             string  `json:"status"`
	StartDate          string  `json:"start_date"`
	NextChargeDate     string  `json:"next_charge_date"`
	StopDate           string  `json:"stop_date"`
	EndMonthFlag       string  `json:"end_month_flag"`
	ErrorCode          string  `json:"error_code"`
	ClientField1       string  `json:"client_field_1"`
	ClientField2       string  `json:"client_field_2"`
	ClientField3       string  `json:"client_field_3"`
	Remarks            string  `json:"remarks"`
	Created            string  `json:"created"`
	Updated            string  `json:"updated"`
}
type GetSubscriptionResponse struct {
	ID                 string  `json:"id"`
	ShopID             string  `json:"shop_id"`
	PayType            PayType `json:"pay_type"`
	PlanID             string  `json:"plan_id"`
	PlanName           string  `json:"plan_name"`
	CustomerID         string  `json:"customer_id"`
	CardID             string  `json:"card_id"`
	PaymentMethodID    string  `json:"payment_method_id"`
	Amount             int     `json:"amount"`
	Tax                int     `json:"tax"`
	TotalAmount        int     `json:"total_amount"`
	InitialAmount      int     `json:"initial_amount"`
	InitialTax         int     `json:"initial_tax"`
	InitialTotalAmount int     `json:"initial_total_amount"`
	Status             string  `json:"status"`
	StartDate          string  `json:"start_date"`
	NextChargeDate     string  `json:"next_charge_date"`
	StopDate           string  `json:"stop_date"`
	EndMonthFlag       string  `json:"end_month_flag"`
	ErrorCode          string  `json:"error_code"`
	ClientField1       string  `json:"client_field_1"`
	ClientField2       string  `json:"client_field_2"`
	ClientField3       string  `json:"client_field_3"`
	Remarks            string  `json:"remarks"`
	Created            string  `json:"created"`
	Updated            string  `json:"updated"`
}
プラン申し込みのケース(1)
月額プラン(¥77,000)月の途中(2024年12月16日)から利用開始申込日から課金開始7日間の無料期間あり
申込日からサブスクリプションを開始するパターンです。7日間の無料期間があるので、課金開始日を1週間後の2024年12月23日に設定します。この場合は毎月23日に課金が行われます。
func main() {
	client := fincode.NewFincodeClient()
	// 管理画面から作成したデータのIDを使用
	customerID := "c_1xTBQEx5Qz6Xl7DnJ8RqUA"
	cardID := "cs_sP3jWEe-R-m8cU5fomDLXg"
	planID := "pl_0iii1LkUS-OBbqQDtajmBg"
	// 対象月の金額
	totalAmount := 77000
	// stringに変換
	initialAmountString := strconv.Itoa(totalAmount)
	// 課金開始日を7日後の日付にする(7日間の無料期間を設定)
	now := time.Now()
	startDate := now.AddDate(0, 0, 7).Format("2006/01/02")
	// リクエスト内容を作成
	createSubscriptionRequest := &fincode.CreateSubscriptionRequest{
		PayType:       fincode.PayTypeCard,
		PlanID:        planID,
		CustomerID:    customerID,
		CardID:        &cardID,
		StartDate:     &startDate,
		InitialAmount: &initialAmountString,
	}
	// サブスクリプションの作成
	subscription, err := client.CreateSubscription(createSubscriptionRequest)
	if err != nil {
		log.Panicln(err)
		return
	}
	// レスポンスを出力
	log.Println(subscription)
}
プラン申し込みのケース(2)
月額プラン(¥77,000)月の途中(2024年12月16日)から利用開始月末課金(当月分を月末に課金)日割り課金あり
日割り課金があるので、初月は残り日数の16日分の、(¥77,000 ÷ 31)× 16 = ¥39,741を課金します。(小数点以下は切り捨てています。)月末課金を行うので、月末課金フラグをONにします。月末課金フラグをONにする場合は、課金開始日を月末の日付にする必要があります。
func main() {
	client := fincode.NewFincodeClient()
	// 管理画面から作成したデータのIDを使用
	customerID := "c_1xTBQEx5Qz6Xl7DnJ8RqUA"
	cardID := "cs_sP3jWEe-R-m8cU5fomDLXg"
	planID := "pl_0iii1LkUS-OBbqQDtajmBg"
	// 対象月の金額
	totalAmount := 77000
	// 対象月の日数
	now := time.Now()
	firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
	nextMonthFirstDay := firstDay.AddDate(0, 1, 0)
	targetMonthDays := nextMonthFirstDay.Sub(firstDay).Hours() / 24
	// 対象月の残日数(当日を含める)
	remainingDays := (targetMonthDays - float64(now.Day())) + 1
	// 日割り課金金額
	remainingTotalAmount := (float64(totalAmount) / targetMonthDays) * remainingDays
	// int型に変換して端数を切り捨てる
	initialAmount := int(remainingTotalAmount)
	// stringに変換
	initialAmountString := strconv.Itoa(initialAmount)
	// 課金開始日を月末の日付にする
	startDate := nextMonthFirstDay.AddDate(0, 0, -1).Format("2006/01/02")
	// 月末課金フラグをONにする
	endMonthFlag := "1"
	// リクエスト内容を作成
	createSubscriptionRequest := &fincode.CreateSubscriptionRequest{
		PayType:       fincode.PayTypeCard,
		PlanID:        planID,
		CustomerID:    customerID,
		CardID:        &cardID,
		StartDate:     &startDate,
		InitialAmount: &initialAmountString,
		EndMonthFlag:  &endMonthFlag,
	}
	// サブスクリプションの作成
	subscription, err := client.CreateSubscription(createSubscriptionRequest)
	if err != nil {
		log.Panicln(err)
		return
	}
	// レスポンスを出力
	log.Println(subscription)
}
プラン変更のケース
月額プラン(¥77,000)から月額プラン(¥132,000)にプラン変更2024年11月1日から利用開始申込日から課金開始月の途中(2024年12月16日)にプラン変更日割り課金あり
プラン変更は、サブスクリプションの登録と解約を組み合わせて行います。2024年11月1日に利用開始しているので、課金日は毎月1日です。2024年12月16日にプラン変更をするので、変更前のプランの利用期間は2024年12月1日から2024年12月15日で、2024年12月16日から2024年12月31日までの期間の金額は、新しいサブスクリプションの初回利用金額から割り引きます。割引後の金額¥132,000 -(¥77,000 ÷ 31)× 16 = ¥92,258既存のサブスクリプションは、新しいサブスクリプションを登録してから解約処理を行います。
func main() {
	client := fincode.NewFincodeClient()
	// 管理画面から作成したデータのIDを使用
	customerID := "c_1xTBQEx5Qz6Xl7DnJ8RqUA"
	cardID := "cs_sP3jWEe-R-m8cU5fomDLXg"
	planID := "pl_bXTFEfvYTlSOw-ceQGHVVQ"
	subscriptionID := "su_7uH6dVQbRpiXpLwBg2yvvA"
	// 変更前のサブスクリプションの取得
	previousSubscription, err := client.GetSubscription(subscriptionID)
	if err != nil {
		log.Panicln(err)
		return
	}
	// 変更前プランの次回課金日
	nextChargeDate, err := getNextChargeDate(previousSubscription.NextChargeDate)
	if err != nil {
		log.Panicln(err)
		return
	}
	// 変更前プランの前回課金日
	previousChargeDate, err := getPreviousChargeDate(previousSubscription.StartDate, *nextChargeDate)
	if err != nil {
		log.Panicln(err)
		return
	}
	// 変更前プランの次回課金日と前回課金日の差の日数
	targetDays := nextChargeDate.Sub(*previousChargeDate).Hours() / 24
	// 当日の日付
	now := time.Now()
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
	// 変更前プランの残日数(当日を含めない)
	remainingDays := nextChargeDate.Sub(today).Hours() / 24
	// 変更後の課金開始日は当日の日付
	nextStartDate := now.Format("2006/01/02")
	// 変更前のプランの金額
	previousTotalAmount := 77000
	// 変更後のプランの金額
	nextTotalAmount := 132000
	// 変更前のプランの残りの期間の金額
	remainingTotalAmount := (float64(previousTotalAmount) / targetDays) * remainingDays
	// 変更後のプランの金額から変更前のプランの残りの期間の金額を引く
	nextInitialAmount := float64(nextTotalAmount) - remainingTotalAmount
	// int型に変換して端数を切り捨てる
	initialAmount := int(nextInitialAmount)
	log.Println("initialAmount", initialAmount)
	// stringに変換
	initialAmountString := strconv.Itoa(initialAmount)
	// リクエスト内容を作成
	createSubscriptionRequest := &fincode.CreateSubscriptionRequest{
		PayType:       fincode.PayTypeCard,
		PlanID:        planID,
		CustomerID:    customerID,
		CardID:        &cardID,
		StartDate:     &nextStartDate,
		InitialAmount: &initialAmountString,
	}
	// 新しいサブスクリプションの作成
	subscription, err := client.CreateSubscription(createSubscriptionRequest)
	if err != nil {
		log.Panicln(err)
		return
	}
	// レスポンスを出力
	log.Println(subscription)
	//既存のサブスクリプションの解約
	deleteSubscription, err := client.DeleteSubscription(subscriptionID)
	if err != nil {
		log.Panicln(err)
		return
	}
	//レスポンスを出力
	log.Println(deleteSubscription)
}
func getNextChargeDate(nextChargeDateString string) (*time.Time, error) {
	t, err := time.Parse("2006/01/02 15:04:05", nextChargeDateString)
	if err != nil {
		return nil, err
	}
	nextChargeDate := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
	return &nextChargeDate, nil
}
func getPreviousChargeDate(startDate string, nextChargeDate time.Time) (*time.Time, error) {
	// 変更前プランの課金開始日
	t, err := time.Parse("2006/01/02 15:04:05", startDate)
	if err != nil {
		return nil, err
	}
	// 次回課金日の月の初日
	firstDayOfNextChargeMonth := time.Date(nextChargeDate.Year(), nextChargeDate.Month(), 1, 0, 0, 0, 0, time.Local)
	// 次回課金日の前月の最終日
	lastDayOfPreviousMonth := firstDayOfNextChargeMonth.AddDate(0, 0, -1)
	// 次回課金日の前月における課金開始日の日の日付
	chargeStartDayOfPreviousMonth := time.Date(lastDayOfPreviousMonth.Year(), lastDayOfPreviousMonth.Month(), t.Day(), 0, 0, 0, 0, time.Local)
	// 前回の課金日
	// 課金日の日付が存在しない場合(30日までの月で31日が課金日の場合など)は、
	// 月の最終日が課金日となるので、その場合は最終日を返す。
	var previousChargeDate time.Time
	if lastDayOfPreviousMonth.Year() < chargeStartDayOfPreviousMonth.Year() || lastDayOfPreviousMonth.Month() < chargeStartDayOfPreviousMonth.Month() {
		previousChargeDate = lastDayOfPreviousMonth
	} else {
		previousChargeDate = chargeStartDayOfPreviousMonth
	}
	return &previousChargeDate, nil
}
まとめ
今回はfincode byGMOの使用例についての記事でした。紹介したサブスクリプション機能以外にも、決済機能や請求書機能などがあるので、決済関連で必要な要件はfincode byGMOで一通り実現できると思います。今後決済機能を実装する際の参考にしていただけると良いです。