LINE Messaging API + LINE Pay API + GASで、昼食弁当注文システムのプロトタイプを作ってみた


##はじめに
<font color="RED">※これは、LINE BOTとLINE Payで何かできないかと思って作った架空のサービスのプロトタイプです。</font>


みなさん、オフィスや現場で昼食はどうされていますか？


お弁当持参の方、コンビニに買いに行く方、外食される方、
あと、オフィスにお弁当屋さんが配達してくれるお弁当を食べてる方も多いことでしょう。

今回はオフィスにお弁当屋さんが配達してくれるケースの課題に着目したプロトタイプを作ってみました。


##現状と課題

オフィスにお弁当屋さんが配達してくれるケースは、一般的には以下の流れではないでしょうか。

オフィスの食堂には、社員の名前が記載された１ヶ月分の弁当スケジュール表が張り出されていて、各人が○×を記入します。

（イメージ図）
<img width="80%" alt="注文表" src="https://i.gyazo.com/de96d2cc65b8a5217a9a5bb513771409.png">


毎朝、お弁当屋さんは契約企業のオフィスに電話をかけて、「今日のお弁当の数」をきき、お昼前に配達します。
お弁当の代金は、月末締め翌月給料日にまとめて支払うことになってます。
月末になると、お弁当屋さんは、１ヶ月分を締めて、会社へ請求書を送ります。
会社の事務員は、従業員の給料引き落としの処理をして、給料日にお弁当屋さんへ代金を振り込みします。

これらの関係を図にすると以下になります。
お弁当屋さん、会社の事務員さんともに、面倒な事務があります。

[![Image from Gyazo](https://i.gyazo.com/8859ae9a5aba86fa98ac842d5ce085a8.png)](https://gyazo.com/8859ae9a5aba86fa98ac842d5ce085a8)


##解決方法
以上の面倒な事務を効率化するための方法を考えました。

<B>- 直接、従業員がLINEのチャットボットで注文し、LINEPayで決済する。
- チャットボットの注文は自動的にGoogleスプレッドシートに記録される。</b>

<img width="70%" alt="解決方法" src="https://i.gyazo.com/a1237f4ca91881f34645660daa233acd.png">

これで、お弁当屋さんと会社の事務員さんの事務負担が軽減されると思います。

##作ったもの
[![Image from Gyazo](https://i.gyazo.com/9b78a446bf70c206cc966f9498941eb0.gif)](https://gyazo.com/9b78a446bf70c206cc966f9498941eb0)



###流れ
[![Image from Gyazo](https://i.gyazo.com/f19bb8fc87c6d7788bce9db9f21ad2ee.png)](https://gyazo.com/f19bb8fc87c6d7788bce9db9f21ad2ee)

[![Image from Gyazo](https://i.gyazo.com/2477c528a5919f70c8dd1a6498af9c97.png)](https://gyazo.com/2477c528a5919f70c8dd1a6498af9c97)

##システム構成図
[![Image from Gyazo](https://i.gyazo.com/242bfeeaa733eeb12d1f5c987742d006.png)](https://gyazo.com/242bfeeaa733eeb12d1f5c987742d006)


##開発環境
node.js v14.5.0
Heroku


##まずすること

####LINE公式アカウント作成
こちらを参考にしました。
[LINE公式アカウントの作り方｜開設の設定と運用方法](https://www.linebiz.com/jp/column/technique/20190418-3/)

####LINE BOTの 設定
こちらを参考にしました。
[1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017](https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d)

####LINEリッチテキストメニューの設定
こちらを参考にしました。
[【公式】リッチメニューの活用法│作成から設定方法まで解説](https://www.linebiz.com/jp/column/technique/20180731-01/)

[LINE Official Account Manager](https://manager.line.biz/)から、該当のアカウントを選択し、右側のトークルーム管理 > リッチメニュー
[![Image from Gyazo](https://i.gyazo.com/4bf6f106110d0a371ee9dbdc457231d9.png)](https://gyazo.com/4bf6f106110d0a371ee9dbdc457231d9)



####LINE Pay Sandbox （テスト用のLINE PAY）の設定
こちらにアクセスして設定
https://pay.line.me/jp/developers/techsupport/sandbox/creation?locale=ja_JP
テストID、パスワードがメールで送られてくる。

LINE Payコンソール（メール記載のリンク先） > 決済連動管理 > 連動キー管理
Channel ID と Channel Secret Keyを確認

[![Image from Gyazo](https://i.gyazo.com/75ba7ef603479b309e6ad4e505b89b24.png)](https://gyazo.com/75ba7ef603479b309e6ad4e505b89b24)


####Googleスプレッドシートの作成・設定
データベースとしてGoogleスプレッドシートを使いました。
Googleスプレッドシートの導入部分はこちらを参考
[Node.js googleapis npmパッケージで Google スプレッドシートを await/async で読み取るメモ　〜1ft-seabass.jp.MEMO](https://www.1ft-seabass.jp/memo/2020/05/08/npm-googleapis-google-sheet-await-async/)

こんなシート
[![Image from Gyazo](https://i.gyazo.com/8268f574030d5a0e9e9bad4b0b721cd9.png)](https://gyazo.com/8268f574030d5a0e9e9bad4b0b721cd9)



##コード

###LINE BOT

Githubで公開してます。
https://github.com/tatsuya1970/linepayTest

```main.js
"use strict"
require("dotenv").config();

const server = require("express")();
const cache = require("memory-cache");
const debug = require("debug")("pay-test");

// Importing LINE Pay API SDK
const linePay = require("line-pay");
const pay = new linePay({
    channelId: process.env.LINE_PAY_CHANNEL_ID,
    channelSecret: process.env.LINE_PAY_CHANNEL_SECRET,
    hostname: process.env.LINE_PAY_HOSTNAME,
    isSandbox: true // なぜかこれをいれたら通った
});

// Importing LINE Messaging API SDK
const lineBot = require("@line/bot-sdk");
const botConfig = {
    channelAccessToken: process.env.LINE_BOT_ACCESS_TOKEN,
    channelSecret: process.env.LINE_BOT_CHANNEL_SECRET
}
const bot = new lineBot.Client(botConfig);
server.listen(process.env.PORT || 5000);

//Googleスプレッドシートを使う設定
let {google} = require('googleapis');
const creds = {
    "type": "service_account",
    "project_id": process.env.PROJECT_ID,
    "private_key_id": process.env.PRIVATE_KEY_ID,
    "private_key": process.env.PRIVATE_KEY,
    "client_email": process.env.CLIENT_EMAIL,
    "client_id": process.env.CLIENT_ID,
    "auth_uri": process.env.AUTH_URI,
    "token_uri": process.env.TOKEN_URI,
    "auth_provider_x509_cert_url": process.env.AUTH_PROVIDER_X509_CERT_URL,
    "client_x509_cert_url": process.env.CLIENT_X509_CERT_URL
  };

  let jwtClient = new google.auth.JWT(
    creds.client_email,
    null,
    creds.private_key.replace(/\\n/g, '\n'),  ////環境変数をenvにしたときのグーグルではうまくいかないのでこうしたhttps://qiita.com/Horie1024/items/d65abeebaf803beae18b
    ['https://www.googleapis.com/auth/spreadsheets',
     'https://www.googleapis.com/auth/drive']
  );

  
const sheet = process.env.SHEET_ID; //GoogleスプレッドシートのシートID
// スプレッドシートAPIはv4を使う
let sheets = google.sheets('v4');

//LINEスタンプのデータ　https://developers.line.biz/ja/docs/messaging-api/sticker-list/
const packageId = [    8515, 446, 446,    6359,   11537];
const stickerId = [16581243,1989,1997,11069856,52002736];

server.post('/webhook', lineBot.middleware(botConfig), (req, res) => {
   console.log(req.body.events);
        //ここのif分はdeveloper consoleの"接続確認"用なので削除して問題ないです。
        if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
            res.send('Hello LINE BOT!(POST)');
            console.log('疎通確認用');
            return; 
        }
        Promise
          .all(req.body.events.map(handleEvent))
          .then((result) => res.json(result));
});


// Webhook for Messaging API.
async function handleEvent(event) {
    
    if (event.type == "postback"){
        if (event.postback.data == "yes"){
            let reservation = {
                productName: "日替わり弁当　※テストですので実際の支払いは発生しません",
                amount: 1,
                currency: "JPY",
                confirmUrl: process.env.LINE_PAY_CONFIRM_URL || `https://${req.hostname}/pay/confirm`,
                confirmUrlType: "SERVER",
                orderId: `${event.source.userId}-${Date.now()}`
            }
            // Call LINE Pay reserve API.
            pay.reserve(reservation).then((response) => {
                reservation.transactionId = response.info.transactionId;
                reservation.userId = event.source.userId;
                cache.put(reservation.transactionId, reservation);

                let message = {
                    type: "template",
                    altText: "LINE Payでのお支払いになります",
                    template: {
                        type: "buttons",
                        text: "LINE Payでのお支払いになります。\n※テストですので実際の支払いは発生しません",
                        actions: [
                            {type: "uri", label: "了解", uri: response.info.paymentUrl.web},
                        ]
                    }
                }
                // Now we can provide payment URL.
                return bot.replyMessage(event.replyToken, message);
            }).then((response) => {
                return;
            });
        } else {
            // User does not purchase so say good bye.
            let message = {
                type: "text",
                text: "次回のご利用をお待ちしております"
            }
            return bot.replyMessage(event.replyToken, message).then((response) => {
                cache.del(event.source.userId);
                return;
            });
        }
    }

    //GoogleスプレッドシートのJSON Web Token(JWT) の認証
    let resultJwtClient;
    try {
      resultJwtClient = await jwtClient.authorize();
    } catch (error) {
      console.log("Auth Error: " + error);
    }

    //会員リストを読み込む
    let responseGetSheet;
    try {
        responseGetSheet =  await sheets.spreadsheets.values.get({
            auth: jwtClient,
            spreadsheetId: sheet,
            range: "会員リスト",
        });
    } catch (error) {
        console.log('The API returned an error: ' + error);
    }
    //シートから読み込んだデータ
    let range = responseGetSheet.data.range;
    let majorDimension = responseGetSheet.data.mejorDimension;
    let values = responseGetSheet.data.values;

    let userId = event.source.userId; //LINEのuerId

    //カンマを含む場合の処理（会員情報新規・変更）
    if(event.type == "message" && event.message.text.indexOf('、') != -1){
        let firstman =1; //初めてかどうか
        let editRow = "";
        let i = 0; 
        //for文の i=2 は、3行目からという意味
        for (i = 2; i < values.length; i++) {
            if (values[i][0] == userId){
                firstman = 0;
                editRow = String(i+1);
                console.log("editRow:%s",editRow);
                break;
            }
        } 
        let input = event.message.text.split('、');
        let message = "";
        if (firstman == 1){
            message = {
                type: "text",
                text: input[0]+"の"+input[1]+"様で受け付しました"
            }
            appendData("会員リスト!A1",event.source.userId,input[0],input[1])
        }
        if (firstman == 0){
            message = {
                type: "text",
                text: input[0]+"の"+input[1]+"様に修正しました"
            }
            let range="会員リスト!A"+editRow;
            console.log("range:%s",range);
            updateData(range,event.source.userId,input[0],input[1])
        }
　　　　　return bot.replyMessage(event.replyToken, message);
    }
    let firstman =1; //初めてかどうか
    //for文の i=2 は、3行目からという意味
    for (let i = 2; i < values.length; i++) {
        console.log(values[i][0]);
        if (values[i][0] == userId){
            firstman = 0;
            break;
        }
    }
    if (firstman == 1){
        let message = {
            type: "text",
            text: "「支店名、氏名」を入力してください\n（入力例）広島支店、山本浩二\n※支店名と氏名の間に、全角カンマ「、」を入れてください"
        }
        return bot.replyMessage(event.replyToken, message);
    }

    if (event.type == "message") {
        let message = "";
        if(event.message.text === "注文"){
            message = {
                type: "template",
                altText: "注文",
                template: {
                    type: "confirm",
                    text: "日替わり弁当を注文しますか？",
                    actions: [
                        {type: "postback", label: "はい", data: "yes"},
                        {type: "postback", label: "いいえ", data: "no"}
                    ]
                }
            }
        }
        if(event.message.text === "今日の弁当"){
            message = [{
                type: "text",
                text: "今日は唐揚げ弁当です"
            },
            {
                type: "image",
                //今日の弁当の画像の保存場所
                originalContentUrl: "https://brali-image.s3-ap-northeast-1.amazonaws.com/images/bento-img.jpg",
                previewImageUrl: "https://brali-image.s3-ap-northeast-1.amazonaws.com/images/bento-img.jpg"
            }]
            
        }
        if(event.message.text === "注文取消"){     
            message = {
                type: "text",
                text: "注文を取消ししました（未実装）"
            }
        }
        if(event.message.text === "属性修正"){
            message = {
                type: "text",
                text: "「支店名、氏名」を入力してください\n（入力例）広島支店、山本浩二\n※支店名と氏名の間に、全角読点「、」を入れてください"
            }
        }
        return bot.replyMessage(event.replyToken, message);
    }      
}


// If user approve the payment, LINE Pay server call this webhook.
server.get("/pay/confirm", (req, res, next) => {
    if (!req.query.transactionId){
        return res.status(400).send("Transaction Id not found.");
    }
    // Retrieve the reservation from database.
    let reservation = cache.get(req.query.transactionId);
    if (!reservation){
        return res.status(400).send("Reservation not found.")
    }
    let confirmation = {
        transactionId: req.query.transactionId,
        amount: reservation.amount,
        currency: reservation.currency
    }
    return pay.confirm(confirmation).then((response) => {
        res.sendStatus(200);

        //スプレッドシートに注文した人の情報を送信
        let userId = reservation.userId;       
        appendData("今日の注文!A1",userId,"","");
        //スタンプをランダムに選ぶ
        let rand = Math.floor(Math.random() * (packageId.length));

        let messages = [{
            type: "sticker",
            packageId: packageId[rand],
            stickerId: stickerId[rand]
        },{
            type: "text",
            text: "お買い上げありがとうございました"
        }]
        return bot.pushMessage(reservation.userId, messages);
    }).then((response) => {
        cache.put(reservation.userId, {subscription: "active"});
    });
});

//googleスプレッドシートにデータを追加（行を追加）
async function appendData(range0,value1,value2,value3) {
    let responseAppendSheet;
      try {
        responseAppendSheet = await sheets.spreadsheets.values.append({
        auth: jwtClient,
        spreadsheetId: sheet,
        range: range0,
        valueInputOption: "USER_ENTERED",
        insertDataOption : "INSERT_ROWS",
        resource : {
          values : [[value1,value2,value3]]
        }
      });
    } catch (error) {
       console.log('The API returned an error: ' + error);
    }
}

//googleスプレッドシートのデータをアップデート（上書き）
async function updateData(range0,value1,value2,value3) {
    let responseAppendSheet;
      try {
        responseAppendSheet = await sheets.spreadsheets.values.update({
        auth: jwtClient,
        spreadsheetId: sheet,
        range: range0,
        valueInputOption: "USER_ENTERED",
        resource : {
          values : [[value1,value2,value3]]
        }
      });
    } catch (error) {
        console.log('The API returned an error: ' + error);
    }
}
```




<br>


###Googleスプレッドシート側
注文が入ってきたら、ユーザーIDからお客様情報を自動的にシートに書き出すことをGoogle Apps Script（GAS）でやってます。

```js
function myFunction() {
  
  const mySheet = SpreadsheetApp.getActiveSheet(); //シートを取得

  //ほかからの干渉は挿入になり、getActiveCellでは「Exception: 範囲外のセル参照です」というエラーになる
  //getCurrentCell()でとる。
  //トリガーは「変更」、「修正」では反応しない

  //他のカラムに入力するのは無視
  if (mySheet.getCurrentCell().getColumn() != 1 ){ 
    return;
  }

  const myRow = mySheet.getCurrentCell().getRow() 
  const myCell = mySheet.getRange(myRow,1);
  const orderID = myCell.getValue();
  const memberlistSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("会員リスト");

  let orderCompany;
  let orderName;

  //アクティブセルがシート「今日の注文」かを判定
  if( mySheet.getName()=="今日の注文" && myRow > 2 ){ 
      
      for (let i = 3; i <= memberlistSheet.getLastRow(); i++) {

          if(orderID == memberlistSheet.getRange(i,1).getValue()){
            orderCompany = memberlistSheet.getRange(i,2).getValue();
            orderName = memberlistSheet.getRange(i,3).getValue();
            break;
          }    
      }

    const color="#FFFFFF";
    mySheet.getRange(myRow, 1,  1, 4).setBackground(color);
    mySheet.getRange(myRow, 2).setValue(orderCompany);
    mySheet.getRange(myRow, 3).setValue(orderName);

    const date = new Date();
    const timestamp = Utilities.formatDate(date, 'JST', 'YYYY-MM-dd HH:mm:ss');
    mySheet.getRange(myRow, 4).setValue(timestamp);
  }

  if( mySheet.getName()=="会員リスト" && myRow > 2 ){ 
    const color="#FFFFFF";
    mySheet.getRange(myRow, 1,  1, 4).setBackground(color);
    const date = new Date();   
    const timestamp = Utilities.formatDate(date, 'JST', 'YYYY-MM-dd HH:mm:ss');
    mySheet.getRange(myRow, 4).setValue(timestamp);
  }

}
```


####参考にしたサイト
- Herokuでの環境変数の設定
[Herokuで環境変数を設定する方法（Githubに秘匿情報を載せない方法）](http://tatsuya1970.com/?p=15618)

- LINE Payの実装 （ほぼコピペ）
[LINE Pay APIを使ってアプリに決済を組み込む方法](https://qiita.com/nkjm/items/b4f70b4daaf343a2bedc)

- Googleスプレッドシートの読み書き （ほぼコピペ）
[【Node.js + Sheets API v4】Googleスプレッドシートを読み書きする](https://qiita.com/vicugna-pacos/items/f7bb0d97bbaa1371edc8)