📝

LINE botに課金機能を実装する方法【Stripe】

2023/09/30に公開

はじめに

鉄血鉄血...
今日も"鉄血"について考えていたら1日が終わっていました。

さて、我らが鉄血会では登録者9万人超えのChuChuをはじめとした様々なLINEアカウントを開発しています。(スゴイ!)
しかしながらこれらがなかなかコストがかかるもんで、ついに課金機能を搭載する必要が出てきたんですよね。(ツラい...)

そこで、LINE botに課金機能を実装する方法を色々リサーチすることになりました。

まずは技術選定ですが、ここは実装のしやすさなどを考慮して無難にStripeを導入するのがいいだろうということになりました。他のサービスでもよく使われていますしね。

Stripeについて調べてみてわかったのですが、Stripeは様々なAPIを提供しており、Stripe × LINEで課金を実装しようと思っても実は選択肢が結構多いんです。
例えば、LINEのリッチメニューから直接決済ページに飛べるようにしようと思うとLIFFを使う必要があって、フロントを書く必要が出てくるので結構な手間になります。
一度Nuxt.jsを使ってこれを実装したことがあるのですが、ロード時間がネックとなってボツになりました笑(機会があればこの方法も紹介します)

そんなこんなで色々試行錯誤してみた結果、ユーザー体験や実装の手間を考慮した個人的なベストプラクティスが見つかったので今回はそれを紹介していきます!

なお、GitHubにサンプルコードを上げているのでこちらも見ながら進めていくとわかりやすいかと思います。
テスト環境の構築方法はREADMEを読んでください。

一連の流れ

  1. リッチメニューに"/shop"のメッセージアクションを設定
  2. "/shop"メッセージを受け取ったら料金一覧のFlex Messageを表示
  3. Flex Messageのボタンにポストバックアクションを設定し、押下で決済リンクを生成
  4. リンクからStripeの決済ページに飛ばし、決済情報をwebhookで受け取る
  5. 決済完了

トークルームの様子

実装

リッチメニューの実装は本質的でないので省略します。

料金一覧のFlex Messageを表示

まず、表示するべきFlex Messageを作成しましょう。
完成形

サンプルコード

flexMessage.js
export const checkout = () => {
    return {
        type: "flex",
        altText: "決済ページ",
        contents: {
            "type": "bubble",
            "size": "giga",
            "body": {
                "type": "box",
                "layout": "vertical",
                "spacing": "lg",
                "contents": [
                    priceBox(50, 120, process.env.PRICE_ID_50),
                    priceBox(320, 730, process.env.PRICE_ID_320),
                    priceBox(700, 1480, process.env.PRICE_ID_700),
                    priceBox(1720, 3600, process.env.PRICE_ID_1720),
                ]
            }
        }
    }
}

const priceBox = (point, price, priceId) => {
    return {
        "type": "box",
        "layout": "horizontal",
        "spacing": "none",
        "contents": [
            {
                "type": "text",
                "text": `🅿${point}`,
                "size": "md",
                "align": "start",
                "gravity": "center",
                "weight": "bold",
                "wrap": true,
                "margin": "none",
            },
            {
                "type": "button",
                "style": "primary",
                "height": "sm",
                "margin": "none",
                "gravity": "center",
                "action": {
                    "type": "postback",
                    "label": `¥${price}`,
                    "data": `priceID=${priceId}&point=${point}&price=${price}`
                }
            }
        ]
    }
}

ボタンにpostbackアクションを設定し、dataプロパティにStripeのpriceIDやもらえるポイント数、課金額を渡すことで今後の処理で使えるようにしています。

あとは、"/shop"メッセージを受け取った際にこのFlex Messageを表示するようにすればいいだけです。
以下の分岐を追加しましょう。

handler.js
if(event.type === 'message'){
    if(event.message.text === '/shop'){
        return await lineClient.replyMessage(event.replyToken, checkout())
    }
}

ポストバックイベントから決済リンクを生成

postbackイベントを受け取る処理

handler.js
else if(event.type === 'postback'){
    if(event.postback.data.startsWith("priceID")){
        return await handleStripePostback(event)
    }
}

handleStripePostback関数の記述

  1. postbackイベントによって送られてきた諸々の値を取得
handler.js
const userId = event.source.userId
const queryString = event.postback.data
const params = new URLSearchParams(queryString)

const point = params.get('point')
const priceId = params.get('priceID')
  1. StripeのcustomerIdを生成
    firestore上でLINEのuserIdと紐づいていて、1対1で対応するようにしています。
handler.js
const docRef = firestore.doc(firestore.db, 'users', userId)
const userData = await firestore.getDoc(docRef).then(doc => doc.data())

let customerId

if (!userData?.stripeId) {
    const customer = await stripe.customers.create()

    customerId = customer.id

    try {
        await firestore.setDoc(docRef, { stripeId: customerId }, { merge: true })
    } 
    catch (setCustomerIdError) {
        console.error("setCustomerid Error:", setCustomerIdError)
        return { statusCode: 500, message: `setCustomerid Error: ${setCustomerIdError.message}` }
    }
} 
else {
    customerId = userData.stripeId
}
  1. これまでに取得した値を元に決済リンクを生成
    決済の際にもStripe側のcustomerIdとLINEのuserIdを紐づけたいので、metaDataプロパティにLINEのuserIdpointを与えています。
handler.js
const mode = 'payment'
const sessionConfig = {
    customer: customerId,
    mode: mode,
    line_items: [
        {
          price: priceId,
          quantity: 1,
        },
    ],
    success_url: `https://line.me/R/oaMessage/${process.env.LINE_BOT_ID}`,
    cancel_url: `https://line.me/R/oaMessage/${process.env.LINE_BOT_ID}`,
    allow_promotion_codes: true,
    metadata: { lineID: userId, point: point },
}
if (mode === 'payment') {
    sessionConfig.invoice_creation = { enabled: true }
}

let sessionUrl

try {
    const session = await stripe.checkout.sessions.create(sessionConfig)
    sessionUrl = session.url
}
catch (error) {
    console.error('Stripe session creation failed:', error.message)
}
  1. 生成したリンクを表示するFlex Messageを作成し、postbackアクションに対してリプライ
handler.js
const checkoutMessage = {
    "type": "flex",
    "altText": "This is a Flex Message",
    "contents": {
      "type": "bubble",
      "body": {
        "type": "box",
        "layout": "vertical",
        "contents": [
            {
                "type": "text",
                "text": `${Number(point)}ポイント`,
                "size": "lg",
                "align": "center",
                "weight": "bold"
            },
          {
            "type": "button",
            "action": {
              "type": "uri",
              "label": "決済画面を開く",
              "uri": sessionUrl
            },
            "style": "primary"
          }
        ]
      }
    }
}

await lineClient.replyMessage(event.replyToken, checkoutMessage)

決済情報をwebhookで受け取る

webhookでStripeからのイベントを受け取るstripeEvent関数を記述していきます。

  1. Stripeのwebhookの認証
handler.js
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET
const sig = req.headers['stripe-signature']

let event

try {
    event = stripe.webhooks.constructEvent(req.rawBody, sig, endpointSecret)
} catch (err) {
    console.error("Error constructing event:", err.message)
    return res.status(400).send(`Webhook Error: ${err.message}`)
}
  1. metaDataに設定した値を取得し、決済完了時の処理をする
handler.js
const lineId = event.data.object.metadata.lineID
const docRef = firestore.doc(firestore.db, 'users', lineId)
const userData = await firestore.getDoc(docRef).then(doc => doc.data())

const point = userData?.point || 0
const newPoint = point + Number(event.data.object.metadata.point)

if (event.type === 'checkout.session.completed') {
    const docRef = firestore.doc(firestore.db, 'users', lineId)
    await firestore.setDoc(docRef, { point: newPoint }, { merge: true })
    await lineClient.pushMessage(lineId, {
        type: 'text',
        text: `決済が完了しました!`,
    })
}

エンドポイントを設定

LINE, Stripeのイベントをリッスンするためのエンドポイントをそれぞれ設定します。

index.js
const app = express()

app.post('/webhook/line', async (req, res) => {
    const events = req.body.events
    try{
        await Promise.all(events.map(lineEvent))
        
        return res.status(200).json({
            status: "success",
        })
    } catch (error) {
        console.error('line post Error:', error)
        throw error
    }
})

app.post('/webhook/stripe', bodyParser.raw({ type: 'application/json' }), stripeEvent)

firebase用にエクスポートします。

index.js
export const func = onRequest(
    {
        region: "asia-northeast1",
        cpu: 1,
        memory: "512MiB",
    },
    app
)

実装終わり

おわりに

頑張って書いたのでイイネ押してください

Discussion