2023年11月24日金曜日

Google フォームで決済機能付き注文フォームを実現する

Google フォームと GAS(Google Apps Script)で、クレジットカードによる決済機能付きの注文フォームを無料&サーバーレスで実現します。

データベース代わりとしてスプレッドシート、確認メール等の送信に Gmail、オンライン決済には PAY.JP を使用します。

(システムの構築・運用は無料ですが、決済手数料はかかります)


注文を受けた際の流れは以下のようになります。丸数字付きの太字が注文者の動き、その下がシステムの動きの簡単な説明になります。

-----
① Google フォームから注文
→ Google フォームの回答をスプレッドシートに記録。決済ページのリンクを記載した確認メールを生成・送信する。

② 決済ページにアクセス
→ メールアドレス、注文合計金額が表示された決済ページを生成する。決済ページには有効期限を設定。

③ 決済
→ PAY.JP から Webhook を受け取り、スプレッドシートに決済完了を記録。決済完了のメールを生成・送信する。

④ 注文完了
-----

今回、在庫管理の部分については省いて作成しています。在庫管理については、「【GAS】Google フォームとスプレッドシートを連携して選択肢を動的に入力する。そして注文フォームを作ってみる。」を参考にしてみてください。

また、GAS によるシンプルな PAY.JP の導入については「GAS でオンライン決済 PAY.JP を導入する」をご参照ください。


***

それでは、以下 Google フォームを使用した決済機能付き注文フォームの作り方になります。PAY.JP のアカウントは作成済みの想定です(テストモードを使用)。

(決済に関するものですので、一応こうやると動くよという参考程度のものとしてください。ご利用の際は自己責任でお願いします)


1)注文フォームの作成


まずは、Google フォームで簡単な注文フォームを作成します。とりあえず、今回は以下のようにしました。

1) 商品 [プルダウン] ※1~複数個
2) お名前 [記述式](必須)
3) メールアドレス [記述式](必須)
4) お電話番号 [記述式]
5) 住所(続き) [記述式](必須)
6) お支払い方法 [ラジオボタン](必須)



2)回答を記録するスプレッドシートの加工


フォームの回答先となる新しいスプレッドシートを作成します。

「フォームの回答 1」シートの末列に「決済金額」「固有番号」「有効期限」「顧客番号」「決済状況」の5つの列を追加します。

「商品情報」という名前のシートを追加、A列に「商品名」、B列に「価格」を入力しておきます。その際、1行目は項目名として使用し、2行目から追記します。フォームの商品数、順番とそろえてください。



3)注文フォームから注文受付処理用の GAS を作成


フォームから GAS を作成します。フォームから作成することで、トリガー作成時に、イベントのソースとしてフォームを選択することができるようになります。

「注文受付.gs」
const sp = SpreadsheetApp.openById("スプレッドシート ID");
const sh = sp.getSheetByName("フォームの回答 1");
const lastRow = sh.getLastRow();
const lastCol = sh.getLastColumn();
const item_sh = sp.getSheetByName("商品情報");

function processOrder(e) {
  Utilities.sleep(5000);

  FormApp.getActiveForm(); // フォームへのパーミッションを与えるためのおまじない
  const res = e.response.getItemResponses();

  // 商品情報の取得
  const itemCount = item_sh.getLastRow() - 1;
  const item = item_sh.getRange(2, 1, itemCount, 2).getValues();

  // 金額合計を計算
  let amount = 0;
  for(let i = 0; i < itemCount; i++) {
    if(res[i].getResponse() != "") {
      amount += res[i].getResponse() * item[i][1];
    }
  }
  sh.getRange(lastRow, lastCol - 4).setValue(amount); // 合計金額を書き込み

  const id = generateId(); // 固有番号を生成
  sh.getRange(lastRow, lastCol - 3).setValue(id); // 固有番号を書き込み

  // 固有番号の有効期限を計算
  const timeStamp = new Date(sh.getRange(lastRow, 1).getValue());
  let expirationTimestamp = new Date(timeStamp.setHours(timeStamp.getHours() + 1));
  expirationTimestamp = Utilities.formatDate(expirationTimestamp, "JST", "yyyy/MM/dd HH:mm:ss");
  sh.getRange(lastRow, lastCol -2).setValue(expirationTimestamp); // 有効期限を書き込み

  sendConfirmationEmail(res, itemCount, item, amount, id); // 確認メールの送信
}
1行目の「スプレッドシート ID」を書き換えてください。

processOrder 関数を、フォーム送信時に起動するようにトリガー設定をしておきます。


「固有番号の生成.gs」
function generateId() {
  const previousIdSet = sh.getRange(2, lastCol - 3, lastRow - 1, 1).getValues(); // 生成済みの固有番号を取得

  let id;
  let isDuplicate;

  do {
    id = generateRandomString(); // ランダムな文字列を生成
    isDuplicate = checkForDuplicate(id, previousIdSet); // 重複をチェック
  } while (isDuplicate);

  return id;
}

function checkForDuplicate(value, previousValue) {
  for (var i = 0; i < previousValue.length; i++) {
    if (previousValue[i][0] === value) {
      return true; // 重複が見つかった場合
    }
  }
  return false; // 重複がない場合
}

function generateRandomString() {
  const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  let randomString = "";
  for (var i = 0; i < 30; i++) {
    let randomIndex = Math.floor(Math.random() * characters.length);
    randomString += characters.charAt(randomIndex);
  }
  return randomString;
}


「確認メールの送信.gs」
function sendConfirmationEmail(res, itemCount, item, amount, id) {
  // メール本文の作成
  const name = res[itemCount].getResponse(); // お名前
  const email = res[itemCount + 1].getResponse(); // メールアドレス
  const phone = res[itemCount + 2].getResponse(); // お電話番号
  const address = res[itemCount + 3].getResponse(); // 住所
  const payment = res[itemCount + 4].getResponse(); // お支払方法

  let order = [];
  for(let i = 0; i < itemCount; i++) {
    order.push(item[i][0] + " " + res[i].getResponse() + "個");
  }
  let body = name + " 様\n\nご注文ありがとうございます。\n\n以下の内容でご注文を承りました。ご確認ください。\n\n-----\n" + order.join("\n") + "\n\n合計 ¥" + amount + "\n-----\n\nお届け先住所:" + address + "\n電話番号:" + phone + "\n\nお支払い方法:" + payment;

  if(payment == "クレジットカード") {
    const url = "ウェブアプリの URL";
    body += "\n\nご注文内容を確認の上、以下リンク先よりお支払いを完了してください:\n" + url + "?id=" + id;
  } else if(payment == "銀行振込") {
    body += "\n\nご注文内容を確認の上、以下振込先へのお支払いを完了してください:" + "\n〇〇銀行\n△△支店\n普通 ×××××××";
  }
 
  // 確認メールの送信
  GmailApp.sendEmail(
    email,
    "ご注文内容の確認と決済ページのご案内",
    body,
    {
      from: "送信元メールアドレス",
      name: "送信者名"      
    }
  );
}
28行目の「送信元メールアドレス」、29行目の「送信者名」を書き換えてください。

16行目の「ウェブアプリの URL」は後ほど書き換えます。


「決済ページの生成.gs」
function doGet(e) {
  const PUBLISH_KEY = "公開鍵";
  const html = HtmlService.createTemplateFromFile('index');

  // URL から固有番号を取得
  const id = e.parameter["id"];
  if(id == null) {
    return HtmlService.createHtmlOutput("<center>無効な URL です。(1)</center>"); // 固有番号がない場合はエラーページを表示
  }

  // 対象の固有番号の行を特定
  const idSet = sh.getRange(2, lastCol - 3, sh.getLastRow(), 1).getValues();
  let rowNum = 0;
  for(let i = 0; i < idSet.length; i++) {
    if(id == idSet[i]) {
      rowNum = 2 + rowNum + i;
      break;
    }
  }
  if(rowNum == 0) {
    return HtmlService.createHtmlOutput("<center>無効な URL です。(2)</center>"); // 有効な固有番号がない場合はエラーページを表示
  } else if(sh.getRange(rowNum, lastCol).getValues() != "") {
    return HtmlService.createHtmlOutput("<center>無効な URL です。(3)</center>"); // 既に決済完了している場合はエラーページを表示
  }

  // 該当の注文を取得
  const order = sh.getRange(rowNum, 1, 1, lastCol - 1).getValues();

  // 有効期限のチェック
  const expirationTimestamp = order[0][lastCol - 1]; // 有効期限
  const currentTime = new Date(); // 現在時刻
  if(expirationTimestamp < currentTime) {
    return HtmlService.createHtmlOutput("<center>有効期限を過ぎた決済ページです。(4)</center>"); // 有効期限を過ぎた場合はエラーページを表示
  }

  // 決済情報
  const email = order[0][lastCol - 9]; // メールアドレス
  const amount = order[0][lastCol - 5]; // 決済金額

  // 決済情報を index.html へ
  html.rowNum = rowNum;
  html.email = email;
  html.amount = amount;
  html.PUBLISH_KEY = PUBLISH_KEY;

  return html.evaluate();
}

// PAY.JPでの決済処理
function doPost(e) {  
  const card = e.parameter["payjp-token"];
  const rowNum = e.parameter["rowNum"];
  const amount = e.parameter["amount"];
  const SECRET_KEY = PropertiesService.getScriptProperties().getProperty("SECRET_KEY");

  let customer =  UrlFetchApp.fetch("https://api.pay.jp/v1/customers", {
    "method" : "post",
    "payload" : {
      "card": card
    },
    "headers": {'Authorization': "Basic " + Utilities.base64Encode(SECRET_KEY + ":")}
  });
  customer = JSON.parse(customer);

  sh.getRange(rowNum, lastCol - 1).setValue(customer.id); // 顧客番号を書き込み

  UrlFetchApp.fetch("https://api.pay.jp/v1/charges", {
    "method" : "post",
    "payload" : {
      "amount": amount,
      "currency": "JPY",
      "customer": customer.id
    },
    'headers' : {'Authorization': "Basic " + Utilities.base64Encode(SECRET_KEY + ":")}
  });

  return HtmlService.createHtmlOutput("<center>" + amount + "円のお支払いが完了しました。<br /><br />ブラウザを閉じてください。</center>");
}
2行目の「公開鍵」を書き換えてください。公開鍵は、PAY.JP のテスト公開鍵になります。

54行目の「SECRET_KEY」は、「プロジェクトの設定」の「スクリプト プロパティ」に保存してください。値は、PAY.JP のテスト秘密鍵になります。


「index.html」
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <base target="_top">
    <title>注文フォーム 決済ページ(テスト)</title>
  </head>
  <body>
    <center>
    <h1>注文フォーム 決済ページ(テスト)</h1>
    <p>メールアドレスと決済金額をご確認の上、お支払いを完了させてください。</p><br />
    <form action="ウェブアプリの URL" method="post">
      <input type="hidden" id="rowNum" name="rowNum" value=<?= rowNum?> />
      <label>メールアドレス: <?= email?><input type="hidden" id="email" name="email" value=<?= email?> /></label><br />
      <label>決済金額: <?= amount?><input type="hidden" id="amount" name="amount" value=<?= amount?> /></label><br /><br />
      <script src="https://checkout.pay.jp/" class="payjp-button" data-key=<?= PUBLISH_KEY?>></script>
    </form>
    </center>
  </body>
</html>
12行目の「ウェブアプリの URL」は後ほど書き換えます。


コードが一通りできたら、デプロイをします。

GAS の「デプロイ」→「新しいデプロイ」から「種類の選択」で「ウェブアプリ」を選択。

・説明:任意
・ウェブアプリ / 次のユーザーとして実行:自分
・ウェブアプリ / アクセスできるユーザー:全員

として、「デプロイ」を実行。

「確認メールの送信.gs(16行目)」と「index.html(12行目)」の該当箇所を表示されたウェブアプリの URL に書き換えます。

「デプロイ」→「デプロイの管理」から編集で「新バージョン」を再度デプロイします。


4)Webhook 受取用の GAS の作成


PAY.JP からの Webhook 受取用に、決済ページとは別の URL を持たせたウェブアプリが必要となるため、新たな GAS を作成します。こちらの GAS はスプレッドシートから作成しても、紐づけなしで作成しても構いません。

「webhook受取.gs」
const sp = SpreadsheetApp.openById("スプレッドシート ID");
const sh = sp.getSheetByName("フォームの回答 1");
const lastRow = sh.getLastRow();
const lastCol = sh.getLastColumn();

function doPost(e) {
  const contents = JSON.parse(e.postData.contents);

  // Webhook の確認
  const cusSet = sh.getRange(2, lastCol - 1, lastRow - 1, 2).getValues(); // 顧客番号一覧の取得

  // 顧客番号が存在するかの確認
  const cus = contents.data.customer; // 顧客番号
  for(let i = 0; i < cusSet.length; i++) {
    if(cusSet[i][0] == cus) {
      if(contents.type == "charge.succeeded") {
        sh.getRange(i + 2, lastCol).setValue("完了"); // 決済完了を書き込み
        paymentConfirmationEmail(i + 2); // 決済完了メールの送信
      } else {
        sh.getRange(i + 2, lastCol).setValue(contents.type); // 決済失敗
      }
    }
  }
}
1行目の「スプレッドシート ID」を書き換えてください。

※ Webhook のヘッダーに含まれている X-Payjp-Webhook-Token を、GAS の doPost 関数で取得する方法がわからなかった(できない?)ので、顧客番号で Webhook の正当性を判断する形にしています。


「決済完了メールの送信.gs」
function paymentConfirmationEmail(rowNum) {
  const name = sh.getRange(rowNum, lastCol - 9).getValue();
  const email = sh.getRange(rowNum, lastCol - 8).getValue();

  let body = name + " 様\n\n決済が完了しました。\n\n発送まで今しばらくお待ちください。\n\nどうぞ宜しくお願い致します。";

  // 決済完了メールの送信
  GmailApp.sendEmail(
    email,
    "決済が完了しました",
    body,
    {
      from: "送信元メールアドレス",
      name: "送信者名"      
    }
  );
}
13行目の「送信元メールアドレス」、14行目の「送信者名」を書き換えてください。


こちらもデプロイをします。

GAS の「デプロイ」→「新しいデプロイ」から「種類の選択」で「ウェブアプリ」を選択。

・説明:任意
・ウェブアプリ / 次のユーザーとして実行:自分
・ウェブアプリ / アクセスできるユーザー:全員

として、「デプロイ」を実行。

ウェブアプリの URL をメモしておきます。

こちらの URL は知られないように管理してください。


5)PAY.JP で Webhook の設定

PAY.JP の「API設定」で、Webhookの追加をします。URL は、先ほどメモした Webhook 受取用の GAS のウェブアプリの URL となります。


これで完成となります。


***

思ったよりも手順が多くなりました。実際に運用する際には、もう少しエラーハンドリング等しっかりやる必要がありそうですが、とりあえず、一通り動くものができてよかったです。