LINE Pay APIを使った決済機能を作ってみた Node.js版 (Version3対応)

# 概要
LINE Pay APIを利用したいと考えていましたが、version3の記事が少なかったと感じたので使い方について紹介していければと思います。
また、Node.js版のSDKがversion3ではなさそうだったので、直接APIを実行しています。
（現在はPython版SDKしかない模様？）
今回はLINE botを利用した決済機能という想定で作成しています。

# LINE Pay APIについて
LINEユーザーがLINE pay加盟店のサイトで利用できる決済システムを提供しています。
LINE Pay APIを利用するためには加盟店登録を行う必要がありますが、検証目的でのSandbox環境も提供されているので、試してみたい方はこちらの環境を利用します。
登録すれば`Channel Id`と`Channel SecretKey`が渡されますので、こちらを利用していきます。

[Sandbox環境の利用方法はこちら]
(https://www.puti-banbeena.com/cgi/2019/11/01/linepay-sandbox/)

# LINE Pay API Version3の主な変更点
**・Request API**
*以下抜粋*
*version2ではIDとパスワードによる認証方法を使用していましたが、version3ではHMACを利用して認証とメッセージ検証を行う方法に変わります。version2でパスワードを渡していた`X-LINE-ChannelSecret`は、加盟店側で保存してください。HMAC署名を作成するときは、SecretKeyを使用します。X-LINE-Authorization-Nounceにランダムで生成される値を指定することで、署名のセキュリティを強化できます。*

認証周りでの変更があったような感じですね。
この辺りについても後ほど説明します。

# LINE Pay APIを使うための準備
**・LINE PayのSandbox環境**
実際にLINEPayを利用するためには、加盟店登録をする必要がありますが、お試しで利用する場合はSandbox環境で機能検証することができます。
**・MessageAPIのChannel**
LINEのBotを利用するためにMessageAPIのChannelを作成します。
[Messaging APIについてはこちらの記事を参考にすると良いかと思います]
(https://qiita.com/nkjm/items/38808bbc97d6927837cd)

# 決済機能のデモ
こちらのようなイメージで決済機能を実装していきます。
![qiita-linepay.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1457643/903e7c91-78d1-626d-b67b-04ed748f646f.gif)

1. ユーザーのアクションに対してbotが応答し、「商品明細を確認し、決済をしてください。」というメッセージを応答
2. ユーザーが確認ボタンを押下
3. botから「こちらから決済を進めてください。」というメッセージを応答
4. ユーザーがLINE Payで決済ボタンを押下
5. LINE Payでの決済画面が立ち上がり、決済を実行

# 解説
大きく２つに分けて解説します。
**・Messaging API**
`1. ユーザーのアクションに対してbotが応答し、「商品明細を確認し、決済をしてください。」というメッセージを応答
 2. ユーザーが確認ボタンを押下
 3. botから「こちらから決済を進めてください。」というメッセージを応答`

**・LINE Pay　API**
`4. ユーザーがLINE Payで決済ボタンを押下
 5. LINE Payでの決済画面が立ち上がり、決済を実行`

## Messaging APIの処理

`1. ユーザーのアクションに対してbotが応答し、「商品明細を確認し、決済をしてください。」というメッセージを応答
2. ユーザーが確認ボタンを押下
3. botから「こちらから決済を進めてください。」というメッセージを応答`

```js
'use strict'

const line = require('@line/bot-sdk')
const express = require('express')
const LinePay = require('./linePay')
const payment = require('./payment')
require('dotenv').config()

const config = {
    channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
    channelSecret: process.env.CHANNEL_SECRET,
};

const client = new line.Client(config);
line.middleware(config);

const app = express();
app.post('/webhook', line.middleware(config), (req, res) => {
    Promise
        .all(req.body.events.map(handleEvent))
        .then((result) => res.json(result));
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
    console.log(`listening on ${port}`);
});


// 1. ユーザーのイベント実行時（メッセージ送信時）に実行
async function handleEvent(event) {
    switch(event.type) {
// 2. 「確認」ボタンはpostbackイベントとして処理
        case 'postback':
            handlePostbackEvent(event)
            return
        default:
            break;
    }

    return client.replyMessage(event.replyToken, {
        type: 'template',
        altText: '商品明細を確認し、決済をしてください。',
        template: {
            type: 'confirm',
            text: '商品明細を確認し、決済をしてください。',
            actions: [
                { type: 'postback', label: '確認', data: 'yes'},
                { type: 'postback', label: '確認しない', data: 'no'}
            ]
        }
    })
}
// 3. ポストバックイベントを実行
const handlePostbackEvent = async (event) => {
    switch(event.postback.data) {
        case 'yes':
            const linePay = new LinePay()
            const result = await linePay.request(payment)
            console.log(result)
            
            return client.replyMessage(event.replyToken,{
                type: 'template',
                altText: 'こちらから決済を進めてください。',
                template: {
                    type: 'buttons',
                    text: 'こちらから決済を進めてください。',
                    actions: [
                        { type: 'uri', label: 'LINE Payで決済', uri: result.info.paymentUrl.web },
                    ]
                }
            })


        case 'no':
            return client.replyMessage(event.replyToken, {
                type: 'text',
                text: 'またのご利用お待ちしております'
            })
    }
}

```
まずは、ユーザーのメッセージ送信時に`handleEvent`メソッドが実行されbotから応答メッセージとして2つのpostbackアクションを持ったテンプレートが返されます。
返却されたメッセージに対して、「確認」ボタンを押下すると、`handlePostbackEvent`メソッドが実行されます。

## LINE Pay APIの処理
`4. ユーザーがLINE Payで決済ボタンを押下
 5. LINE Payでの決済画面が立ち上がり、決済を実行`

```js
const fetch = require('node-fetch')
require('dotenv').config()
const uuid = require('uuid')
const crypto = require('crypto')
const jsonBigInt = require('json-bigint')({ storeAsString: true })

class linePay {
     async _headers(path, body = '') {
        const channelId = process.env.LINE_PAY_CHANNEL_ID //あなたのchannelIdをセット
        const channelSecret = process.env.LINE_PAY_CHANNEL_SECRETKEY //あなたのsecretkeyをセット 
        if(channelId === undefined || channelSecret === undefined) {
            throw new Error('line-pay secret is not found.')
        }

        const nonce = uuid.v4()
        const message = channelSecret + path + body + nonce
        const encrypt = crypto
            .createHmac('sha256', channelSecret)
            .update(message)
        const signature = encrypt.digest('base64')
        return {
            'Content-Type': 'application/json',
            'X-LINE-ChannelId': channelId,
            'X-LINE-Authorization-Nonce': nonce,
            'X-LINE-Authorization': signature,
        }
    }
    async _post(path, payload) {
        const body = JSON.stringify(payload)
        const headers = await this._headers(path, body)
        const url = process.env.LINE_PAY_END_POINT + path
        const res = await fetch(url, { method: 'POST', body, headers })
        const resText = await res.text()
        console.log(resText)
        const resJson = jsonBigInt.parse(resText)
        return resJson
    }

    async request(payment) {
        const products = payment.items.map((item) => {
            return {
                name: item.item_name,
                price: item.tax_included_price,
                quantity: item.quantity,
                originalPrice: item.raw_price,
                imageUrl: item.image_url,
            }
        })
        const packages = [
            {
                id: payment.store_code,
                name: payment.store_name,
                amount: payment.amount,
                products,
            },
        ]
        const options = {
            amount: payment.amount,
            currency: 'JPY',
            orderId: payment.payment_id,
            packages,
            options: { payment: { capture: true } },
            redirectUrls: {
                confirmUrl: process.env.CONFIRM_URL, // 確認画面のURL
                confirmUrlType: 'CLIENT',
                cancelUrl: process.env.CANCEL_URL, // キャンセル画面のURL
            }
        }
        
        const result = await this._post('/v3/payments/request', options)
        if(!result.info || result.returnCode !== '0000') {
            console.log(result)
        }
        return result
    }
}

module.exports = linePay
```

**Request Header**

|Key|Type|Requirement|Description|
|:---|:-----------|:-------------:|:-------------|
| Content-Type  | String  | Y  | 	application/json  |
| X-LINE-ChannelId  | String  | Y  | Payment Integration Information - Channel ID  |
| X-LINE-MerchantDeviceProfileId| String  | N  |Offline Support - Device Type   |
| X-LINE-Authorization-Nonce| String  | Y  |	UUID or Request timestamp   |
| X-LINE-Authorization| String  |  Y |	HMAC Base64 Signature   |

公式リファレンスにある通り、上記の内容でheaderを作成しています。
version3のアップデートにもあったUUIDを利用しハッシュを組み合わせたHMAC署名にしています。

**Request Body**
`amount`：決済金額
`currency`：決済通貨
`orderId`：加盟店の注文番号（加盟店が管理するユニークなID）
`packages`：配送単位（一つのパッケージの中に複数の商品を入れることが可能）
`capture`：自動売上確定の有無
`confirmUrl`：ユーザーが決済要求を承認した後、confirmUrlを自動的に呼出 
`confirmUrlType`：CLIENTは決済完了のためにユーザー画面を加盟店のconfirmUrlに遷移
`cancelUrl`：LINE Pay決済画面でユーザーが決済を途中で取り消せば、決済取消画面へ遷移

```js
{
  amount: payment.amount,
  currency: 'JPY',
  orderId: payment.payment_id,
  packages,
  options: { payment: { capture: true } },
  redirectUrls: {
       confirmUrl: process.env.CONFIRM_URL,
       confirmUrlType: 'CLIENT',
       cancelUrl: process.env.CANCEL_URL,
 }
}
```

`payment`と`packages`にはそれぞれ以下が格納されています。

```js
const items = [
    {
        image_url: 'https://picsum.photos/200',
        item_id: 'item_1ifetpyzm1',
        item_name: 'テスト　アイテム',
        tax_included_price: 330,
        raw_price: 300,
        quantity: 1
    }
]

const payment = {
    payment_id: 'abc123',
    amount: 330,
    items,
    store_code: 'abc123',
    store_name: 'abc123'
}

const products = payment.items.map((item) => {
 return {
          name: item.item_name,
          price: item.tax_included_price,
          quantity: item.quantity,
          originalPrice: item.raw_price,
          imageUrl: item.image_url,
        }
})

const packages = [
 {
   id: payment.store_code,
   name: payment.store_name,
   amount: payment.amount,
   products,
 },
]

```

**Responce Body**
以下の内容でresponceが返されます。成功すれば、`returnCode`が**000**で返却され、決済画面に遷移するために`info.paymentUrl.web`をポストバックアクションの`uri`に設定してあげると完了です。

| Item | type | Length | Description |
|:-|:-:|:-:|:-|
| returnCode   | String  | 4  |	結果コード   |
| returnMessage  | String  | 300  | 結果メッセージ  |
| info.transactionId  | Number  | 19  | 	取引番号  |
| info.paymentAccessToken  | String  | 12  | 	LINE PayでScannerを利用する代わりにコードを入力する場合、そのコード値  |
| info.paymentUrl.app  | String  | 300  | 決済画面に遷移するアプリのURL   |
| info.paymentUrl.web  | String  | 300  | 	決済画面に遷移するWeb URL  |

# 最後に
最後まで読んでいただきありがとうございました。
Sandbox環境ということもあり、本番環境とは少し動作が異なることは仕方ないですが、簡単に試せることはいいですね。
LINE Pay APIは個人事業主でも加盟店登録すれば利用可能なのでぜひ導入してみてはいかがでしょうか。

# 宣伝
パーソルプロセス＆テクノロジー株式会社（以下パーソルP&T）、システムソリューション（SSOL）事業部所属の髙井です。

私はモビリティソリューションデザインチームに所属しており、モビリティ（ここでは移動手段全般）に関するサービスを考えたり、アプリを構築したりしております。

いわゆる「MaaS」に取り組んでおります。

私たちが「MaaS」に取り組む中で、現在活用している、もしくは活用する予定の技術やサービスやとりあえず発信したいことなどなど、幅広くチームメンバーと共に執筆していきたいと思います。
メンバーごとに違った内容を発信していきますので、お楽しみに！

また、「MaaS」について詳しく知りたい方は、チームメンバーの吉田が記事を掲載しておりますので、
ぜひそちらをご覧ください。

[「MaaSとは」でたどり着いて欲しい記事 (1/3　前編)]
(https://note.com/ppt_msdg_maas/n/naae427dbc37c)
[「MaaSとは」でたどり着いて欲しい記事 (2/3　中編)]
(https://note.com/ppt_msdg_maas/n/na45c90b2f02f)
[「MaaSとは」でたどり着いて欲しい記事 (3/3　後編)]
(https://note.com/ppt_msdg_maas/n/n8fd724f7db6c)

**最後まで読んでいただきありがとうございました！**