LINE botに課金機能を実装する方法【Stripe】
はじめに
鉄血鉄血...
今日も"鉄血"について考えていたら1日が終わっていました。
さて、我らが鉄血会では登録者9万人超えのChuChuをはじめとした様々なLINEアカウントを開発しています。(スゴイ!)
しかしながらこれらがなかなかコストがかかるもんで、ついに課金機能を搭載する必要が出てきたんですよね。(ツラい...)
そこで、LINE botに課金機能を実装する方法を色々リサーチすることになりました。
まずは技術選定ですが、ここは実装のしやすさなどを考慮して無難にStripeを導入するのがいいだろうということになりました。他のサービスでもよく使われていますしね。
Stripeについて調べてみてわかったのですが、Stripeは様々なAPIを提供しており、Stripe × LINEで課金を実装しようと思っても実は選択肢が結構多いんです。
例えば、LINEのリッチメニューから直接決済ページに飛べるようにしようと思うとLIFFを使う必要があって、フロントを書く必要が出てくるので結構な手間になります。
一度Nuxt.jsを使ってこれを実装したことがあるのですが、ロード時間がネックとなってボツになりました笑(機会があればこの方法も紹介します)
そんなこんなで色々試行錯誤してみた結果、ユーザー体験や実装の手間を考慮した個人的なベストプラクティスが見つかったので今回はそれを紹介していきます!
なお、GitHubにサンプルコードを上げているのでこちらも見ながら進めていくとわかりやすいかと思います。
テスト環境の構築方法はREADMEを読んでください。
一連の流れ
- リッチメニューに"/shop"のメッセージアクションを設定
- "/shop"メッセージを受け取ったら料金一覧のFlex Messageを表示
- Flex Messageのボタンにポストバックアクションを設定し、押下で決済リンクを生成
- リンクからStripeの決済ページに飛ばし、決済情報をwebhookで受け取る
- 決済完了
トークルームの様子
実装
リッチメニューの実装は本質的でないので省略します。
料金一覧のFlex Messageを表示
まず、表示するべきFlex Messageを作成しましょう。
完成形
サンプルコード
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を表示するようにすればいいだけです。
以下の分岐を追加しましょう。
if(event.type === 'message'){
if(event.message.text === '/shop'){
return await lineClient.replyMessage(event.replyToken, checkout())
}
}
ポストバックイベントから決済リンクを生成
postbackイベントを受け取る処理
else if(event.type === 'postback'){
if(event.postback.data.startsWith("priceID")){
return await handleStripePostback(event)
}
}
handleStripePostback
関数の記述
- postbackイベントによって送られてきた諸々の値を取得
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')
- StripeのcustomerIdを生成
firestore上でLINEのuserIdと紐づいていて、1対1で対応するようにしています。
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
}
- これまでに取得した値を元に決済リンクを生成
決済の際にもStripe側のcustomerId
とLINEのuserId
を紐づけたいので、metaData
プロパティにLINEのuserId
とpoint
を与えています。
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)
}
- 生成したリンクを表示するFlex Messageを作成し、postbackアクションに対してリプライ
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
関数を記述していきます。
- Stripeのwebhookの認証
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}`)
}
-
metaData
に設定した値を取得し、決済完了時の処理をする
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のイベントをリッスンするためのエンドポイントをそれぞれ設定します。
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用にエクスポートします。
export const func = onRequest(
{
region: "asia-northeast1",
cpu: 1,
memory: "512MiB",
},
app
)
実装終わり
おわりに
頑張って書いたのでイイネ押してください
Discussion