【GAS公開】JAL PayをZaimに自動連携|利用通知から自動記帳・二重登録対策

家計簿アプリ「Zaim」は非常に便利ですが、JAL Pay(JAL Global WALLET)は公式の自動連携に対応していません(2026年9月時点)。

JAL Payを日常的に使っていると、決済のたびにZaimへ手入力するのは意外と面倒です。さらに、チャージ元のクレジットカードや銀行口座をZaimで自動連携している場合は、チャージと支払いをどう記録するかも悩みどころです。

そこで今回は、Google Apps Script(GAS)を使って、Gmailに届くJAL Payの利用通知メールを読み取り、Zaimへ自動記帳する仕組みを紹介します。

この方法では、

  • JAL Payの決済 → Zaimへ「支出」として自動登録
  • JAL Payへのチャージ → 一旦「収入」として仮登録
  • 利用日時・金額などから同一取引を判定して二重登録を防止
  • 元メールの利用年月日時刻をZaimのメモに残す
  • 海外通貨は誤登録を避けるため自動登録しない
  • 初回は直近7日、通常は直近1日の通知だけを確認して処理を軽量化

という運用にしています。

この記事の方法はJAL・Zaim・Googleが公式に提供する連携機能ではありません。
各サービスの仕様変更により動作しなくなる可能性があります。ご自身の環境で内容を確認したうえで利用してください。

ANA Pay版については、以下の記事で紹介しています。

目次

この自動化プログラムの特徴

1.JAL Pay決済は「支出」として自動登録

JAL Payから届く「ショッピングご利用」メールをGASが読み取り、利用日・利用金額をZaimへ自動登録します。JAL Payの利用通知メールには店舗名が記載されないため、Zaim上の店舗名は一律でJAL Pay決済としています。その代わり、メモ欄には元メールの利用日時を、たとえば次のように残します。
🤖GAS自動登録 2026/09/08 14:32
後からレシートや利用明細と照合するときに、年月日・時刻・金額を手掛かりに店舗名やカテゴリーを修正できます。

2.チャージはあえて「収入」として仮登録

JAL Payへのチャージは本来の収入ではありませんが、このスクリプトでは一旦「収入」としてJAL Pay口座に登録します。これは、チャージ元のクレジットカードや銀行口座をZaimで自動連携している場合、元口座側の取引がZaimへ反映されるまで時間差があるためです。
先にGAS側で振替を作ってしまうのではなく、

  1. JAL Payのチャージ通知を受信
  2. GASが「収入」として仮登録
  3. チャージ元口座の取引がZaimへ自動連携
  4. 仮登録した収入を手動で「振替」に変更

という順番にしています。チャージのメモには、次のように「振替待ち」と残します。

🤖GAS自動登録 2026/09/08 09:15(振替待ち)

チャージの「収入」は仮登録です
振替へ変更するまでは、Zaim上の収入額が一時的に多く表示されます。
チャージ元口座の取引が自動連携された後、忘れずに振替へ変更してください。

3.通知メールではなく「取引情報」を主軸に重複判定

単純に「日付+金額」だけで判定すると、同じ日に同額の決済を2回した場合に、2件目を誤って重複扱いする可能性があります。

そこで本スクリプトでは、主に次の情報から取引を識別します。

  • 取引種別(決済/チャージ)
  • 利用年月日時刻
  • 金額
  • 通貨

さらに、GmailのメッセージIDも補助的な二重登録防止に使います。ただし、メールIDはGAS内部でハッシュ化して保存するだけで、Zaimのメモ欄には表示しません。

つまり、「通知メールそのもの」ではなく、メールに記載された取引を主軸に判定する設計です。

処理済み情報について
二重登録防止のための処理済み情報は、このGASプロジェクト内のユーザープロパティに保存されます。GASプロジェクトを作り直したり、処理済み情報を削除した場合は、直近の取引が再登録される可能性があるためご注意ください。

4.海外通貨は誤登録を避けるため自動スキップ

海外でJAL Payを使うと、通知メールに 40 GBP のように外貨で金額が記載される場合があります。
この数字だけをZaimへ送ると「40円」と誤登録してしまうため、今回のコードではJPY以外の取引を自動登録しません。海外利用分は、後日利用明細で日本円換算額を確認してから手動で登録してください。

5.通常運用は軽量化し、想定外の大量登録は保留

初回実行時は直近7日分の通知を確認しますが、その後は6時間おきに直近1日分だけを検索します。
また、通常の自動実行では、一度に処理する未処理通知を最大20件に制限しています。

21件以上の未処理通知が見つかった場合は、一部だけを登録するのではなく、その回はZaimへ1件も登録せず処理を保留します。必要な場合は、後述する jalPayToZaimManual を手動実行すると最大100件まで処理できます。

極めてまれな重複判定について
JAL Pay通知に店舗名や取引番号が含まれないため、同じ時刻・同じ金額・同じ通貨の取引を複数回行った場合は同一取引と判定される可能性があります。通常利用では起こりにくいケースですが、完全に識別できるわけではありません。

事前準備

本記事のスクリプトを利用するには、以下の準備が必要です。

  • Googleアカウントを持っていること
    Google Apps Script(GAS)の作成・実行に使用します。
    また、同じGoogleアカウントのGmailでJAL Payの利用通知メールを受信している必要があります。
  • Zaimアカウントを持っていること
    自動記帳先として使用するZaimアカウントが必要です。
    本記事では、このアカウントでZaim Developersの設定も行います。
  • ZaimにJAL Pay記録用の手入力の口座を作成済みであること
    本スクリプトでは、JAL Payの決済・チャージをこの手入力口座に記帳します。
    まだ作成していない場合は、事前にZaimでJAL Pay記録用の手入力口座を作成してください。
    Zaimの連携先の追加画面から「財布など」を選択し、任意の名前の手入力口座を作成します。

自動連携のセットアップ手順

プログラミング経験がなくても8ステップで手順通りに設定できます。
慣れていれば15〜30分程度が目安です。

STEP 1:Zaim Developersでアプリ登録をする

まず、GASからZaimへデータを書き込むための「鍵」を発行します。

  1. Zaim Developers にアクセスし、今回自動記帳したいZaimアカウントでログインします。
  2. 「新しいアプリケーションを追加」を選択します。
  3. 以下の内容を入力し、保存します。
項目設定
名称JALPayToZaim(任意の名前でOK)
サービス種ブラウザアプリ
概要JALPay利用通知をZaimへ自動登録
組織個人
サービスのURLひとまず https://example.com
家計簿の記録を読み込むチェック
家計簿の記録を書き込むチェック
家計簿へのアクセスを永続的に許可するチェック
アイコン画像設定不要

間違えやすいポイント
「サービス種」はブラウザアプリを選び、アクセスレベルの3項目にはすべてチェックを入れてください。
本記事の自動連携では、Zaimの情報取得・書き込み・定期的な自動実行に使用します。

出典:Zaim Developers(2026年9月時点)※画面構成や表記は変更される場合があります。
  1. 登録したアプリをクリックし、表示される以下の2つを控えます。
    • Consumer Key(コンシューマID)
    • Consumer Secret(コンシューマシークレット)
出典:Zaim Developers(2026年9月時点)※画面構成や表記は変更される場合があります。

STEP 2:GASの準備とOAuth1ライブラリ追加

  1. Google Apps Script にアクセスし、「新しいプロジェクト」を作成します。
  2. 画面左上の「無題のプロジェクト」をクリックし、分かりやすい名前(例:JALPayToZaim)を付けます。
    • プロジェクト名について
      Zaim Developersのアプリ名とGASプロジェクト名は、動作上は同じである必要はありません。
      本記事では分かりやすさのため、どちらも JALPayToZaim に統一します。
  3. 「プロジェクトの設定(歯車)」を開き、タイムゾーンが 「(GMT+09:00) 東京」 になっていることを確認します。
  4. 「エディタ<>」を開き、左メニューの「ライブラリ」の「+」をクリックします。
  5. 次のスクリプトIDを入力して、OAuth1 ライブラリを追加します。

    1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s
出典:Google Apps Script(2026年9月時点)※画面構成や表記は変更される場合があります。
  1. 追加後、ライブラリに OAuth1 が追加されていることを確認してください。
    コードではこの名前でライブラリを呼び出します。
出典:Google Apps Script(2026年9月時点)※画面構成や表記は変更される場合があります。

STEP 3:Zaim Developersの「サービスのURL」を設定する

  1. GASの「プロジェクトの設定(歯車)」を開き、「スクリプトID」をコピーします。
出典:Google Apps Script(2026年9月時点)※画面構成や表記は変更される場合があります。
  1. 次のURLの <スクリプトID> 部分を、コピーしたIDに置き換えます。

    https://script.google.com/macros/d/<スクリプトID>/usercallback
  1. Zaim Developersのアプリ編集画面に戻り、「サービスのURL」 に上記URLを入力して保存します。
出典:Zaim Developers(2026年9月時点)※画面構成や表記は変更される場合があります。

STEP 4:スクリプトプロパティを登録してコードを貼り付ける

GASの「プロジェクトの設定」→「スクリプト プロパティを追加」から、STEP 1で取得した2つの値を登録します。

  • ZAIM_CONSUMER_ID:コンシューマID
  • ZAIM_CONSUMER_SECRET:コンシューマシークレット
出典:Google Apps Script(2026年9月時点)※画面構成や表記は変更される場合があります。

続いて「エディタ」に戻り、最初から入っているコードを削除して、以下をすべて貼り付けて保存します。

コード全文を表示する
/**
 * JAL Pay → Zaim 自動記帳
 *
 * 【通常運用】
 * ・6時間おきのトリガーを想定
 * ・直近2日分のJAL Pay通知を確認
 * ・未処理通知は1回最大20件まで
 *
 * 【初回・手動実行】
 * ・直近7日分を確認
 * ・最大100件まで処理
 *
 * 【記帳ルール】
 * ・JAL Pay決済:支出として登録
 * ・JAL Payチャージ:収入として仮登録
 *   (チャージ元口座の自動連携後、手動で振替へ変更)
 *
 * 【重複防止】
 * ・主判定:種別+利用日時+金額+通貨
 * ・GmailメッセージID:補助判定として内部のみで使用
 * ・内部IDはZaimのメモ欄には表示しない
 * ・ZaimへPOSTする前に同一取引の登録有無を確認
 * ・POSTで通信例外が出ても、同一実行内では再POSTせずGETで確認
 */

const SCRIPT_PROPS = PropertiesService.getScriptProperties();
const USER_PROPS = PropertiesService.getUserProperties();

const CONSUMER_KEY = SCRIPT_PROPS.getProperty("ZAIM_CONSUMER_ID");
const CONSUMER_SECRET = SCRIPT_PROPS.getProperty("ZAIM_CONSUMER_SECRET");

// 初回・手動実行は直近7日、通常実行は直近2日を確認
const INITIAL_LOOKBACK_DAYS = 7;
const NORMAL_LOOKBACK_DAYS = 2;

// 1回の実行で処理する未処理通知の上限
const NORMAL_MAX_NEW_MESSAGES = 20;
const WIDE_MAX_NEW_MESSAGES = 100;

// Gmail検索は50スレッドずつページング
const GMAIL_PAGE_SIZE = 50;

// 想定外に大量のメールが存在した場合の検索安全弁
const NORMAL_MAX_SCANNED_MESSAGES = 100;
const WIDE_MAX_SCANNED_MESSAGES = 300;

// Zaim照合時に1日分を最大100件取得する。
// 100件ちょうど返った場合は全件取得できたと断定せず、安全のため自動登録を停止する。
const ZAIM_VERIFY_LIMIT = 100;

// 通常実行が24時間以上成功していない場合は手動実行を案内
const STALE_NORMAL_HOURS = 24;

const INITIALIZED_KEY = "JALPAY_INITIALIZED";
const LAST_SUCCESS_AT_KEY = "JALPAY_LAST_SUCCESS_AT";

// 処理済みトークンは日ごとに保存し、14日より古いものを削除
const DONE_BUCKET_PREFIX = "JALPAY_DONE_";
const DONE_TOKEN_RETENTION_DAYS = 14;

// 1実行中のProperties読み込みを減らすためのキャッシュ
const DONE_BUCKET_CACHE = {};

function getService() {
  return OAuth1.createService("Zaim")
    .setAccessTokenUrl("https://api.zaim.net/v2/auth/access")
    .setRequestTokenUrl("https://api.zaim.net/v2/auth/request")
    .setAuthorizationUrl("https://auth.zaim.net/users/auth")
    .setConsumerKey(CONSUMER_KEY)
    .setConsumerSecret(CONSUMER_SECRET)
    .setCallbackFunction("authCallback")
    .setPropertyStore(USER_PROPS);
}

function authCallback(request) {
  const service = getService();
  const authorized = service.handleCallback(request);

  return HtmlService.createHtmlOutput(
    authorized
      ? "Zaimの認証が完了しました!このタブを閉じてください。"
      : "認証に失敗しました。"
  );
}

// Zaimの口座・カテゴリー・ジャンルID確認
function checkZaimIds() {
  const service = getService();

  if (!service.hasAccess()) {
    Logger.log("まず jalPayToZaim を実行してZaim認証を完了してください。");
    return;
  }

  Logger.log("=== 口座(入出金元) ID一覧 ===");
  const accounts = JSON.parse(
    service.fetch("https://api.zaim.net/v2/home/account").getContentText()
  ).accounts;

  accounts.forEach(function(account) {
    Logger.log(account.name + ": " + account.id);
  });

  const categoryMap = {};
  const categories = JSON.parse(
    service.fetch("https://api.zaim.net/v2/home/category").getContentText()
  ).categories;

  Logger.log("=== 支出カテゴリー ID一覧 ===");
  categories.forEach(function(category) {
    categoryMap[category.id] = category.name;
    if (category.mode === "payment") {
      Logger.log(category.name + ": " + category.id);
    }
  });

  Logger.log("=== 収入カテゴリー ID一覧 ===");
  categories.forEach(function(category) {
    if (category.mode === "income") {
      Logger.log(category.name + ": " + category.id);
    }
  });

  Logger.log("=== ジャンル(内訳) ID一覧 ===");
  try {
    const genres = JSON.parse(
      service.fetch("https://api.zaim.net/v2/home/genre").getContentText()
    ).genres;

    genres.forEach(function(genre) {
      const parentCategoryName =
        categoryMap[genre.category_id] || "不明なカテゴリ";

      Logger.log(
        "[" + parentCategoryName + "] " +
        genre.name + ": " +
        genre.id
      );
    });
  } catch (e) {
    Logger.log(
      "ジャンル一覧の取得に失敗しました: " +
      e.message
    );
  }
}

/**
 * 通常の自動実行用。
 * 初回だけは自動的に直近7日・最大100件で実行する。
 * 2回目以降は直近2日・最大20件。
 */
function jalPayToZaim() {
  const initialized =
    USER_PROPS.getProperty(INITIALIZED_KEY) === "1";

  if (!initialized) {
    runJalPaySync_(
      "initial",
      INITIAL_LOOKBACK_DAYS,
      WIDE_MAX_NEW_MESSAGES,
      WIDE_MAX_SCANNED_MESSAGES
    );
    return;
  }

  const lastSuccessAt =
    Number(
      USER_PROPS.getProperty(LAST_SUCCESS_AT_KEY) || 0
    );

  if (
    lastSuccessAt &&
    Date.now() - lastSuccessAt >
      STALE_NORMAL_HOURS * 60 * 60 * 1000
  ) {
    throw new Error(
      "JAL Payの自動登録を保留しました。" +
      "前回の正常終了から24時間以上経過しているため、" +
      "通常の" +
      NORMAL_LOOKBACK_DAYS +
      "日検索では取りこぼす可能性があります。" +
      "今回はZaimへ1件も登録していません。" +
      "Google Apps Scriptを開き、" +
      "関数 jalPayToZaimManual を1回実行してください。"
    );
  }

  runJalPaySync_(
    "normal",
    NORMAL_LOOKBACK_DAYS,
    NORMAL_MAX_NEW_MESSAGES,
    NORMAL_MAX_SCANNED_MESSAGES
  );
}

/**
 * 未処理通知が21件以上ある場合などの手動復旧用。
 * 直近7日・最大100件まで処理する。
 */
function jalPayToZaimManual() {
  runJalPaySync_(
    "manual",
    INITIAL_LOOKBACK_DAYS,
    WIDE_MAX_NEW_MESSAGES,
    WIDE_MAX_SCANNED_MESSAGES
  );
}

/**
 * 初回に「過去7日分は取り込まず、
 * これからの通知だけで始めたい」場合に1回だけ実行。
 *
 * Zaimには何も登録せず、
 * 直近7日分を処理済みとして記録する。
 */
function markRecentJalPayMessagesAsProcessed() {
  const lock = LockService.getScriptLock();

  if (!lock.tryLock(5000)) {
    throw new Error(
      "別の処理が実行中です。" +
      "少し待ってから再実行してください。"
    );
  }

  try {
    const result =
      collectPendingJalPayMessages_(
        INITIAL_LOOKBACK_DAYS,
        WIDE_MAX_NEW_MESSAGES,
        WIDE_MAX_SCANNED_MESSAGES
      );

    if (result.scanOverflow) {
      throw new Error(
        "JAL Pay通知の検索件数が上限に達したため" +
        "処理を中止しました。" +
        "処理済み設定は変更していません。"
      );
    }

    if (result.limitOverflow) {
      throw new Error(
        "直近7日間の未処理JAL Pay通知が" +
        "101件以上あります。" +
        "処理済み設定は変更していません。" +
        "対象件数を確認してから再実行してください。"
      );
    }

    // 取引としては重複しているが、
    // メールIDだけ未記録だったもの
    result.duplicateMailMarks.forEach(
      function(mark) {
        rememberDoneTokens_(
          mark.dateKey,
          [mark.token]
        );
      }
    );

    result.pending.forEach(function(item) {
      rememberDoneTokens_(
        item.transactionDateKey,
        ["T:" + item.txToken]
      );

      rememberDoneTokens_(
        item.mailDateKey,
        ["M:" + item.mailToken]
      );
    });

    USER_PROPS.setProperty(
      INITIALIZED_KEY,
      "1"
    );

    USER_PROPS.setProperty(
      LAST_SUCCESS_AT_KEY,
      String(Date.now())
    );

    cleanupOldDoneBuckets_();

    Logger.log(
      "直近" +
      INITIAL_LOOKBACK_DAYS +
      "日分のJAL Pay通知 " +
      result.pending.length +
      "件を処理済みにしました。" +
      "Zaimへの登録は行っていません。"
    );
  } finally {
    lock.releaseLock();
  }
}

function runJalPaySync_(
  mode,
  lookbackDays,
  maxNewMessages,
  maxScannedMessages
) {
  const lock =
    LockService.getScriptLock();

  if (!lock.tryLock(5000)) {
    throw new Error(
      "別の処理が実行中です。" +
      "少し待ってから再実行してください。"
    );
  }

  try {
    const service =
      getService();

    if (!service.hasAccess()) {
      Logger.log(
        "【重要】次のURLを開いて" +
        "Zaimで認証してください:\n%s",
        service.authorize()
      );
      return;
    }

    const settings =
      getZaimSettings_();

    validateZaimSettings_(settings);

    // Zaimへ書き込む前に、
    // 今回の未処理件数をすべて確認する
    const result =
      collectPendingJalPayMessages_(
        lookbackDays,
        maxNewMessages,
        maxScannedMessages
      );

    if (result.scanOverflow) {
      throw new Error(
        "JAL Pay通知の検索件数が" +
        "上限に達したため、" +
        "今回はZaimへ1件も登録していません。" +
        (
          mode === "normal"
            ? "Google Apps Scriptを開き、" +
              "関数 jalPayToZaimManual を" +
              "1回実行してください。"
            : "対象件数を確認してから" +
              "再実行してください。"
        )
      );
    }

    if (result.limitOverflow) {
      throwLimitError_(
        mode,
        maxNewMessages
      );
    }

    // 同一取引の別メールとして判定した
    // メールIDだけを処理済みにする
    result.duplicateMailMarks.forEach(
      function(mark) {
        rememberDoneTokens_(
          mark.dateKey,
          [mark.token]
        );
      }
    );

    let registered = 0;
    let skipped = 0;

    // 同じ日付のZaim明細を
    // 何度もGETしないための実行内キャッシュ
    const zaimMoneyCache = {};

    Logger.log(
      "今回の登録候補: " +
      result.pending.length +
      "件"
    );

    result.pending.forEach(
      function(item) {
        const transaction =
          item.transaction;

        // 外貨を「40 GBP → 40円」のように
        // 誤登録しない
        if (
          transaction.currency !== "JPY"
        ) {
          Logger.log(
            "日本円以外のため" +
            "自動登録をスキップ: " +
            transaction.displayDateTime +
            " " +
            transaction.amount +
            " " +
            transaction.currency
          );

          rememberDoneTokens_(
            item.transactionDateKey,
            ["T:" + item.txToken]
          );

          rememberDoneTokens_(
            item.mailDateKey,
            ["M:" + item.mailToken]
          );

          skipped++;
          return;
        }

        if (
          !Number.isInteger(
            transaction.amount
          )
        ) {
          throw new Error(
            "日本円金額が整数ではないため" +
            "登録できません: " +
            transaction.rawAmount
          );
        }

        let postUrl;
        let payload;

        if (
          transaction.type === "charge"
        ) {
          postUrl =
            "https://api.zaim.net" +
            "/v2/home/money/income";

          payload = {
            mapping: 1,

            category_id:
              settings.incomeCategoryId,

            amount:
              transaction.amount,

            date:
              transaction.date,

            place:
              "JAL Payチャージ",

            to_account_id:
              settings.jalPaymentId,

            comment:
              buildExpectedZaimComment_(
                transaction
              )
          };
        } else {
          postUrl =
            "https://api.zaim.net" +
            "/v2/home/money/payment";

          payload = {
            mapping: 1,

            category_id:
              settings.paymentCategoryId,

            genre_id:
              settings.paymentGenreId,

            amount:
              transaction.amount,

            date:
              transaction.date,

            place:
              "JAL Pay決済",

            from_account_id:
              settings.jalPaymentId,

            comment:
              buildExpectedZaimComment_(
                transaction
              )
          };
        }

        Logger.log(
          "登録開始: " +
          transaction.displayDateTime +
          " " +
          transaction.place +
          " " +
          transaction.amount +
          "円"
        );

        /*
         * POST前にZaim側を確認する。
         *
         * 前回
         * 「Zaim側では登録成功したが、
         * GAS側では成功応答を受け取れなかった」
         * 場合でも二重登録しない。
         */
        const beforePost =
          inspectZaimTransaction_(
            service,
            transaction,
            settings,
            zaimMoneyCache,
            false
          );

        if (
          beforePost.status === "exact"
        ) {
          rememberDoneTokens_(
            item.transactionDateKey,
            ["T:" + item.txToken]
          );

          rememberDoneTokens_(
            item.mailDateKey,
            ["M:" + item.mailToken]
          );

          skipped++;

          Logger.log(
            "既にZaim登録済みのため" +
            "スキップ: " +
            transaction.displayDateTime +
            " " +
            transaction.place +
            " " +
            transaction.amount +
            "円"
          );

          return;
        }

        if (
          beforePost.status === "ambiguous"
        ) {
          throw new Error(
            "Zaimに同日・同額のJAL Pay候補があり、" +
            "同一取引か安全に判定できないため" +
            "自動登録を停止しました: " +
            transaction.displayDateTime +
            " " +
            transaction.amount +
            "円"
          );
        }

        try {
          postForm_(
            service,
            postUrl,
            payload
          );
        } catch (postError) {
          Logger.log(
            "Zaim POSTで例外発生。" +
            "同一実行内では再POSTせず" +
            "登録済みか確認します: " +
            transaction.displayDateTime +
            " " +
            transaction.place +
            " " +
            transaction.amount +
            "円 / " +
            postError.message
          );

          let verified = false;

          try {
            verified =
              verifyZaimRegistrationAfterPostError_(
                service,
                transaction,
                settings,
                zaimMoneyCache
              );
          } catch (verifyError) {
            throw new Error(
              "ZaimへのPOST後に例外が発生し、" +
              "登録済み確認も完了できませんでした。" +
              "二重登録防止のため処理済みにはしていません。" +
              " POSTエラー: " +
              postError.message +
              " / 照合エラー: " +
              verifyError.message
            );
          }

          if (verified) {
            rememberDoneTokens_(
              item.transactionDateKey,
              ["T:" + item.txToken]
            );

            rememberDoneTokens_(
              item.mailDateKey,
              ["M:" + item.mailToken]
            );

            registered++;

            Logger.log(
              "通信例外は発生しましたが" +
              "Zaimへの登録を確認: " +
              transaction.displayDateTime +
              " " +
              transaction.place +
              " " +
              transaction.amount +
              "円"
            );

            return;
          }

          /*
           * 登録を確認できない場合は、
           * 同一実行内では再POSTしない。
           *
           * 処理済みトークンも保存せず、
           * 次回実行時の事前照合に委ねる。
           */
          throw postError;
        }

        // 正常な成功レスポンスを
        // 受け取れた場合だけ処理済みにする
        rememberDoneTokens_(
          item.transactionDateKey,
          ["T:" + item.txToken]
        );

        rememberDoneTokens_(
          item.mailDateKey,
          ["M:" + item.mailToken]
        );

        registered++;

        Logger.log(
          "登録完了: " +
          transaction.displayDateTime +
          " " +
          transaction.place +
          " " +
          transaction.amount +
          "円"
        );
      }
    );

    USER_PROPS.setProperty(
      INITIALIZED_KEY,
      "1"
    );

    USER_PROPS.setProperty(
      LAST_SUCCESS_AT_KEY,
      String(Date.now())
    );

    cleanupOldDoneBuckets_();

    Logger.log(
      "処理完了: 登録 " +
      registered +
      "件 / スキップ " +
      skipped +
      "件"
    );
  } finally {
    lock.releaseLock();
  }
}

function throwLimitError_(
  mode,
  maxNewMessages
) {
  if (mode === "normal") {
    throw new Error(
      "JAL Payの自動登録を保留しました。" +
      "未処理のJAL Pay通知が21件以上あり、" +
      "通常実行の上限20件を超えています。" +
      "今回はZaimへ1件も登録していません。" +
      "Google Apps Scriptを開き、" +
      "関数 jalPayToZaimManual を" +
      "1回実行してください。" +
      "手動実行では最大100件まで処理できます。"
    );
  }

  if (mode === "initial") {
    throw new Error(
      "初回取り込み対象のJAL Pay通知が" +
      (maxNewMessages + 1) +
      "件以上あります。" +
      "今回はZaimへ1件も登録していません。" +
      "対象件数を確認してから再実行してください。"
    );
  }

  throw new Error(
    "手動取り込み対象のJAL Pay通知が" +
    (maxNewMessages + 1) +
    "件以上あります。" +
    "今回はZaimへ1件も登録していません。" +
    "対象件数を確認してから再実行してください。"
  );
}

function getZaimSettings_() {
  return {
    jalPaymentId:
      getNumberProperty_(
        "JAL_PAYMENT_ID"
      ),

    incomeCategoryId:
      getNumberProperty_(
        "DEFAULT_INCOME_CATEGORY_ID"
      ),

    paymentCategoryId:
      getNumberProperty_(
        "DEFAULT_CATEGORY_ID"
      ),

    paymentGenreId:
      getNumberProperty_(
        "DEFAULT_GENRE_ID"
      )
  };
}

function validateZaimSettings_(
  settings
) {
  if (!settings.jalPaymentId) {
    throw new Error(
      "JAL_PAYMENT_ID が未設定です。"
    );
  }

  if (!settings.incomeCategoryId) {
    throw new Error(
      "DEFAULT_INCOME_CATEGORY_ID が未設定です。"
    );
  }

  if (
    !settings.paymentCategoryId ||
    !settings.paymentGenreId
  ) {
    throw new Error(
      "DEFAULT_CATEGORY_ID または " +
      "DEFAULT_GENRE_ID が未設定です。"
    );
  }
}

/**
 * Gmailをページングしながら、
 * まだ処理していないJAL Pay取引を集める。
 *
 * Zaimへの書き込みは
 * ここでは一切行わない。
 */
function collectPendingJalPayMessages_(
  lookbackDays,
  maxNewMessages,
  maxScannedMessages
) {
  const query =
    "newer_than:" +
    lookbackDays +
    'd {subject:"【JAL Pay】" ' +
    'subject:"【JAL Pay/JAL Global WALLET】"}';

  const cutoff =
    Date.now() -
    lookbackDays *
      24 *
      60 *
      60 *
      1000;

  const seenMessageIds = {};
  const seenTransactionTokens = {};
  const pending = [];
  const duplicateMailMarks = [];

  let start = 0;
  let scannedMessages = 0;

  while (true) {
    const threads =
      GmailApp.search(
        query,
        start,
        GMAIL_PAGE_SIZE
      );

    if (threads.length === 0) {
      break;
    }

    for (
      let i = 0;
      i < threads.length;
      i++
    ) {
      const messages =
        threads[i].getMessages();

      for (
        let j = 0;
        j < messages.length;
        j++
      ) {
        const message =
          messages[j];

        const messageId =
          message.getId();

        if (
          seenMessageIds[messageId]
        ) {
          continue;
        }

        seenMessageIds[
          messageId
        ] = true;

        /*
         * スレッド内に検索期間より
         * 古いメールが含まれる場合があるため除外
         */
        if (
          message
            .getDate()
            .getTime() <
          cutoff
        ) {
          continue;
        }

        scannedMessages++;

        if (
          scannedMessages >
          maxScannedMessages
        ) {
          return {
            pending: [],
            duplicateMailMarks: [],
            limitOverflow: false,
            scanOverflow: true
          };
        }

        const transaction =
          parseJalPayMessage_(
            message
          );

        if (!transaction) {
          continue;
        }

        const mailToken =
          shortHash_(
            "MAIL|" +
            messageId
          );

        const mailDateKey =
          formatDateKey_(
            message.getDate()
          );

        if (
          hasDoneToken_(
            mailDateKey,
            "M:" + mailToken
          )
        ) {
          continue;
        }

        const txToken =
          shortHash_(
            "TX|" +
            buildTransactionKey_(
              transaction
            )
          );

        const transactionDateKey =
          transaction.date;

        if (
          hasDoneToken_(
            transactionDateKey,
            "T:" + txToken
          )
        ) {
          duplicateMailMarks.push({
            dateKey:
              mailDateKey,

            token:
              "M:" + mailToken
          });

          continue;
        }

        /*
         * 同じ実行内で同一取引の通知が
         * 複数見つかった場合も重複登録しない
         */
        if (
          seenTransactionTokens[
            txToken
          ]
        ) {
          duplicateMailMarks.push({
            dateKey:
              mailDateKey,

            token:
              "M:" + mailToken
          });

          continue;
        }

        seenTransactionTokens[
          txToken
        ] = true;

        pending.push({
          transaction:
            transaction,

          txToken:
            txToken,

          mailToken:
            mailToken,

          transactionDateKey:
            transactionDateKey,

          mailDateKey:
            mailDateKey
        });

        /*
         * 上限+1件を見つけた時点で、
         * 書き込み前に中止判断できる
         */
        if (
          pending.length >
          maxNewMessages
        ) {
          return {
            pending: [],
            duplicateMailMarks: [],
            limitOverflow: true,
            scanOverflow: false
          };
        }
      }
    }

    start += threads.length;

    if (
      threads.length <
      GMAIL_PAGE_SIZE
    ) {
      break;
    }
  }

  return {
    pending:
      pending,

    duplicateMailMarks:
      duplicateMailMarks,

    limitOverflow:
      false,

    scanOverflow:
      false
  };
}

// JAL Pay通知メールを
// 共通形式の取引データへ変換
function parseJalPayMessage_(
  message
) {
  const subject =
    message.getSubject();

  const body =
    message.getPlainBody();

  const isJalPay =
    subject.indexOf(
      "【JAL Pay】"
    ) !== -1 ||
    subject.indexOf(
      "【JAL Pay/JAL Global WALLET】"
    ) !== -1;

  if (!isJalPay) {
    return null;
  }

  const isCharge =
    subject.indexOf(
      "チャージ完了"
    ) !== -1;

  const isPayment =
    subject.indexOf(
      "ショッピングご利用"
    ) !== -1;

  if (
    !isCharge &&
    !isPayment
  ) {
    return null;
  }

  const dateTime =
    parseJalDateTime_(
      body,
      isCharge
    );

  const amountInfo =
    parseJalAmount_(
      body,
      isCharge
    );

  if (!dateTime) {
    Logger.log(
      "JAL Payの利用日時を" +
      "解析できません: " +
      subject
    );

    return null;
  }

  if (!amountInfo) {
    Logger.log(
      "JAL Payの金額・通貨を" +
      "解析できません: " +
      subject
    );

    return null;
  }

  return {
    source:
      "JAL_PAY",

    type:
      isCharge
        ? "charge"
        : "payment",

    date:
      dateTime.date,

    isoDateTime:
      dateTime.isoDateTime,

    displayDateTime:
      dateTime.displayDateTime,

    amount:
      amountInfo.amount,

    currency:
      amountInfo.currency,

    rawAmount:
      amountInfo.raw,

    place:
      isCharge
        ? "JAL Payチャージ"
        : "JAL Pay決済"
  };
}

// 「ご利用日時」「チャージ日時」を解析
function parseJalDateTime_(
  body,
  isCharge
) {
  const label =
    isCharge
      ? "チャージ日時"
      : "ご利用日時";

  const lineRegexp =
    new RegExp(
      escapeRegExp_(label) +
      "[::]\\s*([^\\r\\n]+)"
    );

  const lineMatch =
    body.match(lineRegexp);

  if (!lineMatch) {
    return null;
  }

  const raw =
    lineMatch[1].trim();

  let dateMatch =
    raw.match(
      /(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日/
    );

  // 表記変更への保険:
  // 2026-09-08 / 2026/09/08
  if (!dateMatch) {
    dateMatch =
      raw.match(
        /(\d{4})[-\/]([0-1]?\d)[-\/]([0-3]?\d)/
      );
  }

  if (!dateMatch) {
    return null;
  }

  let timeMatch =
    raw.match(
      /([0-2]?\d):([0-5]?\d)(?::([0-5]?\d))?/
    );

  // 14時32分 / 14時32分05秒 にも対応
  if (!timeMatch) {
    timeMatch =
      raw.match(
        /([0-2]?\d)時\s*([0-5]?\d)分(?:\s*([0-5]?\d)秒)?/
      );
  }

  /*
   * 時刻は重複判定と
   * 後日の照合に使うため、
   * 取得できない場合は自動登録しない
   */
  if (!timeMatch) {
    return null;
  }

  const year =
    String(dateMatch[1]);

  const month =
    pad2_(dateMatch[2]);

  const day =
    pad2_(dateMatch[3]);

  const hour =
    pad2_(timeMatch[1]);

  const minute =
    pad2_(timeMatch[2]);

  const second =
    timeMatch[3]
      ? pad2_(timeMatch[3])
      : null;

  const date =
    year +
    "-" +
    month +
    "-" +
    day;

  const isoDateTime =
    date +
    "T" +
    hour +
    ":" +
    minute +
    (
      second
        ? ":" + second
        : ""
    );

  const displayDateTime =
    year +
    "/" +
    month +
    "/" +
    day +
    " " +
    hour +
    ":" +
    minute +
    (
      second
        ? ":" + second
        : ""
    );

  return {
    date:
      date,

    isoDateTime:
      isoDateTime,

    displayDateTime:
      displayDateTime
  };
}

// 「ご利用金額」
// 「チャージ金額(手数料差引後)」
// を解析
function parseJalAmount_(
  body,
  isCharge
) {
  const regexp =
    isCharge
      ? /チャージ金額(?:[((]手数料差引後[))])?[::]\s*([^\r\n]+)/
      : /ご利用金額[::]\s*([^\r\n]+)/;

  const match =
    body.match(regexp);

  if (!match) {
    return null;
  }

  const raw =
    match[1].trim();

  const amountMatch =
    raw.match(
      /([\d,]+(?:\.\d+)?)/
    );

  if (!amountMatch) {
    return null;
  }

  const amount =
    Number(
      amountMatch[1]
        .replace(/,/g, "")
    );

  if (
    !Number.isFinite(amount) ||
    amount <= 0
  ) {
    return null;
  }

  let currency = null;

  if (
    /[円¥¥]/.test(raw)
  ) {
    currency = "JPY";
  } else {
    const currencyMatch =
      raw.match(
        /\b([A-Z]{3})\b/i
      );

    if (currencyMatch) {
      currency =
        currencyMatch[1]
          .toUpperCase();
    }
  }

  if (!currency) {
    return null;
  }

  return {
    amount:
      amount,

    currency:
      currency,

    raw:
      raw
  };
}

// 取引そのものから
// 重複判定キーを作る
function buildTransactionKey_(
  transaction
) {
  return [
    transaction.source,
    transaction.type,
    transaction.isoDateTime,
    String(transaction.amount),
    transaction.currency
  ].join("|");
}

// Zaimに保存する
// GAS自動登録コメントを1か所で生成
function buildExpectedZaimComment_(
  transaction
) {
  return (
    transaction.type === "charge"
      ? "🤖GAS自動登録 " +
        transaction.displayDateTime +
        "(振替待ち)"
      : "🤖GAS自動登録 " +
        transaction.displayDateTime
  );
}

// Zaim照合用エラーに
// 「再試行可能か」の情報を付ける
function createZaimVerifyError_(
  message,
  retryable
) {
  const error =
    new Error(message);

  error.zaimRetryable =
    retryable;

  return error;
}

/**
 * GAS自動登録コメント内の日時と、
 * 今回の取引日時を比較する。
 *
 * 戻り値:
 *
 * different
 *   = 明確に別日時
 *
 * same_or_uncertain
 *   = 同日時、または秒の有無で
 *     別取引と断定できない
 *
 * uncertain
 *   = GASコメントだが
 *     日時を解析できない
 *
 * not_gas
 *   = GAS自動登録コメントではない
 */
function compareGASCommentTime_(
  comment,
  transaction
) {
  const actual =
    String(
      comment || ""
    );

  if (
    actual.indexOf(
      "🤖GAS自動登録 "
    ) !== 0
  ) {
    return "not_gas";
  }

  const match =
    actual.match(
      /🤖GAS自動登録\s+(\d{4})\/(\d{1,2})\/(\d{1,2})\s+([0-2]?\d):([0-5]\d)(?::([0-5]\d))?/
    );

  if (!match) {
    return "uncertain";
  }

  const txMatch =
    String(
      transaction.isoDateTime || ""
    ).match(
      /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/
    );

  if (!txMatch) {
    return "uncertain";
  }

  const commentMinuteKey = [
    match[1],
    pad2_(match[2]),
    pad2_(match[3]),
    pad2_(match[4]),
    pad2_(match[5])
  ].join("|");

  const transactionMinuteKey = [
    txMatch[1],
    txMatch[2],
    txMatch[3],
    txMatch[4],
    txMatch[5]
  ].join("|");

  // 分まで違えば明確に別取引
  if (
    commentMinuteKey !==
    transactionMinuteKey
  ) {
    return "different";
  }

  const commentSecond =
    match[6] == null
      ? null
      : pad2_(match[6]);

  const transactionSecond =
    txMatch[6] == null
      ? null
      : pad2_(txMatch[6]);

  // 両方に秒があり、
  // 秒まで違えば明確に別取引
  if (
    commentSecond !== null &&
    transactionSecond !== null &&
    commentSecond !==
      transactionSecond
  ) {
    return "different";
  }

  // 秒が片方にない場合は
  // 別取引とは断定しない
  return "same_or_uncertain";
}

/**
 * Zaim APIから指定日の明細を取得する。
 *
 * 通信例外・429・5xxだけを再試行する。
 * 401/403などの4xxは即時停止する。
 *
 * 100件ちょうど返った場合は
 * 全件取得できたと断定できないため、
 * 「未登録」とは判定せず
 * 安全のため停止する。
 */
function fetchZaimMoneyForDate_(
  service,
  date
) {
  const query = [
    "mapping=1",
    "start_date=" +
      encodeURIComponent(date),
    "end_date=" +
      encodeURIComponent(date),
    "limit=" +
      ZAIM_VERIFY_LIMIT
  ].join("&");

  const url =
    "https://api.zaim.net" +
    "/v2/home/money?" +
    query;

  const waits = [
    0,
    1000,
    3000
  ];

  let lastError = null;

  for (
    let i = 0;
    i < waits.length;
    i++
  ) {
    if (waits[i] > 0) {
      Utilities.sleep(
        waits[i]
      );
    }

    let response;

    /*
     * service.fetch() 自体が失敗する
     * 通信例外だけcatchする。
     */
    try {
      response =
        service.fetch(
          url,
          {
            method: "get",
            muteHttpExceptions:
              true
          }
        );
    } catch (transportError) {
      lastError =
        createZaimVerifyError_(
          "Zaim APIへの通信に失敗しました: " +
          transportError.message,
          true
        );

      if (
        i <
        waits.length - 1
      ) {
        continue;
      }

      throw lastError;
    }

    const status =
      response.getResponseCode();

    const body =
      response.getContentText();

    if (
      status >= 200 &&
      status < 300
    ) {
      let data;

      try {
        data =
          JSON.parse(body);
      } catch (parseError) {
        throw createZaimVerifyError_(
          "Zaim APIの明細取得結果を" +
          "JSONとして解析できません: " +
          parseError.message,
          false
        );
      }

      if (
        !data ||
        !Array.isArray(
          data.money
        )
      ) {
        throw createZaimVerifyError_(
          "Zaim APIの明細取得結果が" +
          "想定形式ではありません。",
          false
        );
      }

      /*
       * 100件ちょうどの場合は、
       * 101件目以降が存在しないことを
       * 確認できない。
       *
       * 未登録と誤判定して再POSTするより、
       * 安全のため停止する。
       */
      if (
        data.money.length >=
        ZAIM_VERIFY_LIMIT
      ) {
        throw createZaimVerifyError_(
          "Zaimの" +
          date +
          "の明細が" +
          ZAIM_VERIFY_LIMIT +
          "件取得されました。" +
          "取得結果が上限に達しており" +
          "全件取得を確認できないため、" +
          "二重登録防止のため" +
          "自動登録を停止します。",
          false
        );
      }

      return data.money;
    }

    const retryable =
      status === 429 ||
      status >= 500;

    const httpError =
      createZaimVerifyError_(
        "Zaim API GET HTTP " +
        status +
        ": " +
        body,
        retryable
      );

    /*
     * 429または5xxのみ再試行。
     */
    if (retryable) {
      lastError =
        httpError;

      if (
        i <
        waits.length - 1
      ) {
        continue;
      }

      throw httpError;
    }

    /*
     * 401 / 403 / その他4xxは
     * 再試行しても改善しにくいため
     * 即時停止。
     */
    throw httpError;
  }

  throw (
    lastError ||
    createZaimVerifyError_(
      "Zaim APIから明細を取得できませんでした。",
      true
    )
  );
}

/**
 * 同じ日付のZaim明細を
 * 実行内でキャッシュする。
 *
 * forceRefresh=trueの場合は
 * POST後確認のため必ず再取得する。
 */
function loadZaimMoneyForDate_(
  service,
  date,
  cache,
  forceRefresh
) {
  if (
    !forceRefresh &&
    Object.prototype
      .hasOwnProperty
      .call(
        cache,
        date
      )
  ) {
    return cache[date];
  }

  const money =
    fetchZaimMoneyForDate_(
      service,
      date
    );

  cache[date] =
    money;

  return money;
}

/**
 * コメントが今回の取引を
 * 一意に示しているか確認。
 *
 * 末尾に半角/全角スペースで
 * 補足した場合もexactとして扱う。
 */
function isExactZaimCommentMatch_(
  comment,
  expectedComment
) {
  const actual =
    String(
      comment || ""
    );

  return (
    actual ===
      expectedComment ||
    actual.indexOf(
      expectedComment + " "
    ) === 0 ||
    actual.indexOf(
      expectedComment + " "
    ) === 0
  );
}

// Zaim上の1明細が
// 今回の取引と同日・同額か確認
function isSameDateAmount_(
  moneyItem,
  transaction
) {
  return (
    String(
      moneyItem.date || ""
    ) ===
      String(
        transaction.date
      ) &&
    Number(
      moneyItem.amount
    ) ===
      Number(
        transaction.amount
      )
  );
}

/**
 * コメントは一致しないが、
 * JAL Pay由来の既存明細である
 * 可能性がある候補か確認。
 *
 * カテゴリー・ジャンル・modeは
 * ユーザー編集や振替変更で
 * 変わり得るため必須条件にしない。
 */
function isAmbiguousJalPayCandidate_(
  moneyItem,
  transaction,
  settings
) {
  if (
    !isSameDateAmount_(
      moneyItem,
      transaction
    )
  ) {
    return false;
  }

  const comment =
    String(
      moneyItem.comment || ""
    );

  const expectedComment =
    buildExpectedZaimComment_(
      transaction
    );

  // 完全一致はexact側で処理されるため、
  // ここでは曖昧候補にしない
  if (
    isExactZaimCommentMatch_(
      comment,
      expectedComment
    )
  ) {
    return false;
  }

  const timeRelation =
    compareGASCommentTime_(
      comment,
      transaction
    );

  /*
   * GAS自動登録コメントが残っていて、
   * コメント内日時が明確に別日時なら
   * 別取引と判断できる。
   */
  if (
    timeRelation === "different"
  ) {
    return false;
  }

  /*
   * 同日時、
   * 秒の有無で断定不能、
   * またはGASコメントだが日時解析不能なら、
   * 二重登録防止のため曖昧扱いにする。
   *
   * これにより、
   * ・改行して補足
   * ・空白なしで追記
   * ・チャージを振替に変更し
   *   「(振替待ち)」を削除
   * したケースでも再POSTしない。
   */
  if (
    timeRelation ===
      "same_or_uncertain" ||
    timeRelation ===
      "uncertain"
  ) {
    return true;
  }

  /*
   * GASコメント自体が残っていない場合は、
   * placeまたはJAL Pay口座が残っていれば
   * 同一取引の可能性があるため
   * ambiguousとして停止する。
   */
  if (
    String(
      moneyItem.place || ""
    ) ===
    String(
      transaction.place
    )
  ) {
    return true;
  }

  if (
    Number(
      moneyItem.from_account_id
    ) ===
      Number(
        settings.jalPaymentId
      ) ||
    Number(
      moneyItem.to_account_id
    ) ===
      Number(
        settings.jalPaymentId
      )
  ) {
    return true;
  }

  return false;
}

/**
 * Zaim上で今回の取引を照合。
 *
 * status:
 *
 * exact
 *   = 同一取引を確認
 *
 * ambiguous
 *   = 同一取引の可能性がある候補あり
 *     安全に未登録と断定できない
 *
 * missing
 *   = 登録済み候補なし
 */
function inspectZaimTransaction_(
  service,
  transaction,
  settings,
  cache,
  forceRefresh
) {
  const money =
    loadZaimMoneyForDate_(
      service,
      transaction.date,
      cache,
      forceRefresh
    );

  const expectedComment =
    buildExpectedZaimComment_(
      transaction
    );

  const exactMatches =
    money.filter(
      function(moneyItem) {
        return (
          isSameDateAmount_(
            moneyItem,
            transaction
          ) &&
          isExactZaimCommentMatch_(
            moneyItem.comment,
            expectedComment
          )
        );
      }
    );

  if (
    exactMatches.length > 0
  ) {
    return {
      status:
        "exact",

      count:
        exactMatches.length
    };
  }

  const ambiguousMatches =
    money.filter(
      function(moneyItem) {
        return (
          isAmbiguousJalPayCandidate_(
            moneyItem,
            transaction,
            settings
          )
        );
      }
    );

  if (
    ambiguousMatches.length > 0
  ) {
    return {
      status:
        "ambiguous",

      count:
        ambiguousMatches.length
    };
  }

  return {
    status:
      "missing",

    count:
      0
  };
}

/**
 * POSTで例外が出た場合、
 * 同一実行内ではPOSTを再送せず、
 * Zaim側に登録済みかを
 * GETで数回確認する。
 *
 * exactを確認できた場合だけtrue。
 *
 * ambiguousが残る場合や、
 * GET自体を確認できない場合は
 * 例外にして自動処理を止める。
 */
function verifyZaimRegistrationAfterPostError_(
  service,
  transaction,
  settings,
  cache
) {
  const waits = [
    1500,
    3000,
    6000
  ];

  let sawAmbiguous =
    false;

  let lastVerifyError =
    null;

  for (
    let i = 0;
    i < waits.length;
    i++
  ) {
    Utilities.sleep(
      waits[i]
    );

    try {
      const result =
        inspectZaimTransaction_(
          service,
          transaction,
          settings,
          cache,
          true
        );

      if (
        result.status === "exact"
      ) {
        return true;
      }

      if (
        result.status ===
        "ambiguous"
      ) {
        sawAmbiguous =
          true;

        Logger.log(
          "Zaim登録確認 " +
          (i + 1) +
          "/" +
          waits.length +
          ": 同日・同額のJAL Pay候補があり、" +
          "まだ確定できません"
        );
      } else {
        Logger.log(
          "Zaim登録確認 " +
          (i + 1) +
          "/" +
          waits.length +
          ": まだ同一取引を確認できません"
        );
      }
    } catch (verifyError) {

      /*
       * 401 / 403 /
       * JSON異常 /
       * 100件上限など、
       * 再試行しても改善しないエラーは
       * その場で終了。
       */
      if (
        verifyError &&
        verifyError.zaimRetryable ===
          false
      ) {
        throw verifyError;
      }

      lastVerifyError =
        verifyError;

      Logger.log(
        "Zaim登録確認 " +
        (i + 1) +
        "/" +
        waits.length +
        " に失敗: " +
        verifyError.message
      );
    }
  }

  if (sawAmbiguous) {
    throw createZaimVerifyError_(
      "POST後のZaim確認で" +
      "同日・同額のJAL Pay候補が" +
      "見つかりましたが、" +
      "同一取引か確定できませんでした。" +
      "自動再登録は行いません。",
      false
    );
  }

  if (lastVerifyError) {
    throw createZaimVerifyError_(
      "POST後のZaim登録確認を" +
      "完了できませんでした: " +
      lastVerifyError.message,
      true
    );
  }

  return false;
}

// Zaim APIへPOST
function postForm_(
  service,
  url,
  payload
) {
  const encodedPayload =
    Object.keys(payload)
      .map(function(key) {
        return (
          encodeURIComponent(key) +
          "=" +
          encodeURIComponent(
            payload[key]
          )
        );
      })
      .join("&");

  const response =
    service.fetch(
      url,
      {
        method:
          "post",

        payload:
          encodedPayload,

        muteHttpExceptions:
          true
      }
    );

  const status =
    response.getResponseCode();

  if (
    status < 200 ||
    status >= 300
  ) {
    throw new Error(
      "Zaim API HTTP " +
      status +
      ": " +
      response.getContentText()
    );
  }

  return response;
}

function getNumberProperty_(
  name
) {
  const value =
    SCRIPT_PROPS.getProperty(
      name
    );

  if (!value) {
    return 0;
  }

  const number =
    Number(value);

  return (
    Number.isFinite(number)
      ? number
      : 0
  );
}

// 処理済み情報は
// 日単位のPropertiesに保存する
function doneBucketPropertyKey_(
  dateKey
) {
  return (
    DONE_BUCKET_PREFIX +
    dateKey.replace(
      /-/g,
      ""
    )
  );
}

function loadDoneBucket_(
  dateKey
) {
  const propertyKey =
    doneBucketPropertyKey_(
      dateKey
    );

  if (
    DONE_BUCKET_CACHE[
      propertyKey
    ]
  ) {
    return (
      DONE_BUCKET_CACHE[
        propertyKey
      ]
    );
  }

  const raw =
    USER_PROPS.getProperty(
      propertyKey
    );

  let list = [];

  if (raw) {
    try {
      const parsed =
        JSON.parse(raw);

      if (
        Array.isArray(parsed)
      ) {
        list =
          parsed;
      }
    } catch (e) {
      list = [];
    }
  }

  const bucket = {
    propertyKey:
      propertyKey,

    list:
      list,

    set:
      new Set(list)
  };

  DONE_BUCKET_CACHE[
    propertyKey
  ] = bucket;

  return bucket;
}

function hasDoneToken_(
  dateKey,
  token
) {
  return (
    loadDoneBucket_(
      dateKey
    )
      .set
      .has(token)
  );
}

function rememberDoneTokens_(
  dateKey,
  tokens
) {
  const bucket =
    loadDoneBucket_(
      dateKey
    );

  let changed = false;

  tokens.forEach(
    function(token) {
      if (
        !bucket.set.has(
          token
        )
      ) {
        bucket.set.add(
          token
        );

        bucket.list.push(
          token
        );

        changed =
          true;
      }
    }
  );

  if (changed) {
    USER_PROPS.setProperty(
      bucket.propertyKey,
      JSON.stringify(
        bucket.list
      )
    );
  }
}

function cleanupOldDoneBuckets_() {
  const allProperties =
    USER_PROPS.getProperties();

  const cutoffDate =
    new Date();

  cutoffDate.setDate(
    cutoffDate.getDate() -
    DONE_TOKEN_RETENTION_DAYS
  );

  const cutoffKey =
    Utilities.formatDate(
      cutoffDate,
      Session.getScriptTimeZone(),
      "yyyyMMdd"
    );

  Object.keys(
    allProperties
  ).forEach(
    function(key) {
      if (
        key.indexOf(
          DONE_BUCKET_PREFIX
        ) !== 0
      ) {
        return;
      }

      const datePart =
        key.substring(
          DONE_BUCKET_PREFIX.length
        );

      if (
        /^\d{8}$/.test(
          datePart
        ) &&
        datePart < cutoffKey
      ) {
        USER_PROPS.deleteProperty(
          key
        );

        delete DONE_BUCKET_CACHE[
          key
        ];
      }
    }
  );
}

function formatDateKey_(
  date
) {
  return Utilities.formatDate(
    date,
    Session.getScriptTimeZone(),
    "yyyy-MM-dd"
  );
}

// 生のメールIDや取引情報を
// 保存しないため短いハッシュに変換
function shortHash_(
  text
) {
  const bytes =
    Utilities.computeDigest(
      Utilities.DigestAlgorithm
        .SHA_256,
      text,
      Utilities.Charset
        .UTF_8
    );

  return bytes
    .map(function(byte) {
      const unsigned =
        byte < 0
          ? byte + 256
          : byte;

      return unsigned
        .toString(16)
        .padStart(
          2,
          "0"
        );
    })
    .join("")
    .slice(
      0,
      16
    );
}

function escapeRegExp_(
  text
) {
  return text.replace(
    /[.*+?^${}()|[\]\\]/g,
    "\\$&"
  );
}

function pad2_(
  value
) {
  return String(value)
    .padStart(
      2,
      "0"
    );
}

STEP 5:Zaimの初回認証

  1. GAS画面上部の関数選択から jalPayToZaim を選び、「実行」をクリックします。
  1. 初回実行時はGoogleアカウントへのアクセス許可を求められるので、今回GASを作成したGoogleアカウントを選択します。
  2. 「このアプリは Google で確認されていません」 と表示された場合は、画面下部の 「詳細」 をクリックします。
  3. 続いて 「JALPayToZaim(安全ではないページ)に移動」 などと表示されるリンクをクリックします。
出典:Google Apps Script(2026年9月時点)※画面構成や表記は変更される場合があります。
  1. 表示されるアクセス権限の内容を確認します。
    本スクリプトでは、JAL Payから届く利用通知メールをGmailから検索・読み取るため、Gmailへのアクセス権限が必要です。Googleの許可画面では、Gmailに関する比較的広い権限が表示される場合がありますが、本記事のスクリプトではJAL Pay通知メールの検索・読み取りに使用します。内容を確認し、問題なければ 「次へ」 をクリックします。
    ※アクセス権限を許可した後、Googleから「アカウントへのアクセスを許可した」旨のセキュリティ通知メールが届く場合があります。自分で作成した JALPayToZaim への許可であれば問題ありません。
  2. その後、GASの実行ログに表示されたZaim認証用URLを開き、Zaim側でもアクセスを許可します。
  3. 認証が成功すると、「Zaimの認証が完了しました!このタブを閉じてください。」 と表示されます。これが出れば認証完了です。指示に従いタブを閉じます。

この最初の実行は認証URLを表示して終了するため、この時点ではZaimへの取引登録は行われません。

「このアプリはGoogleで確認されていません」と表示される理由
今回は、自分で作成したGoogle Apps Scriptを自分のGoogleアカウントで実行するため、Googleによる一般公開アプリ向けの確認を受けていません。そのため初回認証時に警告が表示されます。
本記事のコードを自分でGASへ貼り付け、自分で作成した JALPayToZaim プロジェクトであることを確認したうえで進めてください。知らない人から共有されたGASや、内容を確認していないスクリプトでは、この警告を無条件に回避しないでください。

Gmailの権限について
このスクリプトはJAL Pay通知メールの内容を読み取り、利用日時・金額などをZaimへ登録するためにGmailへアクセスします。メールの送信や削除を行う処理はコードに含まれていません。

「現在、ファイルを開くことができません」と表示される場合
複数のGoogleアカウントへ同時ログインしていると、認証時にエラーになる場合があります。GASを使用するGoogleアカウントだけでログインし直すか、シークレットウィンドウで認証URLを開いてください。

STEP 6:Zaimの口座ID・カテゴリーIDを設定する

認証が終わったら、Zaimのどの口座・カテゴリーに記帳するかを設定します。

  1. GAS画面上部の関数一覧から checkZaimIds を選択して「実行」します。
  1. 画面下部の実行ログに、現在のZaimアカウントに登録されている以下のIDが表示されます。
    • 口座
    • 支出カテゴリー
    • 収入カテゴリー
    • ジャンル(内訳)
  2. ログを確認し、使用したい項目の名前の右側に表示されている数字(ID)を控えます。
  3. GAS画面左側の 歯車アイコン「プロジェクトの設定」 を開きます。
  4. 画面を下へスクロールし、「スクリプト プロパティ」 を開きます。
  5. 「スクリプト プロパティを追加」 をクリックします。
  6. 「プロパティ」と「値」に、先ほど確認したIDをそれぞれ入力し、「スクリプト プロパティを保存」 をクリックします。

例えば、実行ログが次のように表示されたとします。

=== 口座(入出金元) ID一覧 ===
JAL Pay: 12345
=== 収入カテゴリー ID一覧 ===
その他: 201
=== 支出カテゴリー ID一覧 ===
その他: 101
=== ジャンル(内訳) ID一覧 ===
[その他] 未分類: 1001

この場合、「プロジェクトの設定」→「スクリプト プロパティ」に次のように登録します。

プロパティ名入力する値役割
JAL_PAYMENT_ID12345事前に作成したZaimの「JAL Pay」口座のID
DEFAULT_INCOME_CATEGORY_ID201チャージを一時的に収入として登録する際のカテゴリーID
DEFAULT_CATEGORY_ID101JAL Pay決済を登録する支出カテゴリーのID
DEFAULT_GENRE_ID1001上記カテゴリー内で使用するジャンルのID

カテゴリーとジャンルについて
DEFAULT_GENRE_ID は、DEFAULT_CATEGORY_ID で選んだ支出カテゴリーに属するジャンルを選んでください。上の例なら「その他」に対して「未分類」を設定しています。

チャージは後から振替へ変更する前提なので、DEFAULT_INCOME_CATEGORY_ID は「その他」など、仮登録だと分かりやすい収入カテゴリーを選んでおくと管理しやすいです。

STEP 7:初回の記帳を実行する

Zaimへの初回記帳では、直近7日分のJAL Pay通知を取り込むかどうかによって手順が異なります。

A:直近7日分をすでにZaimへ入力済みの場合

過去の取引を重複登録しないよう、最初に markRecentJalPayMessagesAsProcessed を1回だけ実行します。

この関数は直近7日分のJAL Pay通知を「処理済み」として記録するだけで、Zaimへの登録は行いません。
実行ログに、
直近7日分のJAL Pay通知 8件を処理済みにしました。Zaimへの登録は行っていません。
のように表示されれば完了です。
※件数は、実際に届いているJAL Pay通知の数によって異なります。

この場合は、そのままSTEP 8のトリガー設定へ進みます。

B:直近7日分もZaimへ取り込みたい場合

  1. GAS画面上部の関数一覧から jalPayToZaim を選択し、「実行」をクリックします。
  2. 初回の記帳実行では、直近7日間のJAL Pay通知を確認し、最大100件までまとめて登録します。
  3. 実行ログの最後に、処理完了: 登録 8件 / スキップ 0件のように表示されれば正常に動作しています。
  4. 実行が完了したらZaimを開き、JAL Payの決済・チャージが想定どおり登録されていることが確認できれば完了です。

STEP 8:6時間おきに自動実行する

最後に、JAL Payの通知を定期的に確認するため、GASのトリガーを設定します。

  1. GAS画面左側の 「トリガー」(時計アイコン)を開きます。
  2. 右下の 「トリガーを追加」 をクリックします。
  3. 以下のように設定します。
  • 実行する関数:jalPayToZaim
  • 実行するデプロイ:Head
  • イベントのソース:時間主導型
  • 時間ベースのトリガーのタイプ:時間ベースのタイマー
  • 時間の間隔:6時間おき
  • エラー通知設定:今すぐ通知を受け取る
GASのトリガー設定例。jalPayToZaim を6時間おきに自動実行します。

設定できたら、右下の 「保存」 をクリックします。

これで、以降は6時間おきにGASが自動実行され、JAL Payの通知メールを確認してZaimへ記帳します。

通常実行では直近1日分の通知を確認し、未処理のものだけを登録するため、同じメールを繰り返し確認しても二重登録されないようにしています。

エラーが発生した場合
トリガー実行中にエラーが発生すると、Google Apps Script側のトリガー失敗通知によって、トリガーを設定したGoogleアカウントへ通知されます。また、GASの「実行数」から実行結果やエラー内容を確認できます。

これでセットアップは完了です。

Tips:自動実行が保留された場合

未処理のJAL Pay通知が21件以上ある場合

本スクリプトでは、通常の自動実行で一度に処理する未処理のJAL Pay通知を20件までに制限しています。
未処理通知が21件以上ある場合、通常実行では一部だけを登録せず、その回はZaimへの登録を行わず処理を保留します。実際に多数のJAL Pay決済を行っており、まとめて登録して問題ない場合は、Google Apps Scriptを開き、関数一覧から jalPayToZaimManual を選択して「実行」してください。
手動実行では、直近7日間の未処理通知を最大100件まで処理します。
通常実行で上限を超えた場合のエラーには、次の操作が必要であることも表示されるため、失敗通知メールまたはGASの「実行数」から確認できます。

※手動実行も直近7日分が対象です。エラー通知を確認したら、なるべく早めに復旧してください。

前回の正常終了から24時間以上経過した場合

前回の正常終了から24時間以上経過すると、直近1日分だけを検索する通常実行では通知を取りこぼす可能性があるため、自動登録を行わず処理を保留します。

この場合は、Google Apps Scriptを開き、jalPayToZaimManual を1回実行してください。手動実行では直近7日分の未処理通知を確認します。

セキュリティ上の注意

この方法では、Google Apps ScriptにGmailのJAL Pay通知を読み取る権限と、Zaimへ家計簿データを書き込む権限を付与します。

次の情報は第三者へ公開しないでください。

  • Consumer Key / Consumer Secret
  • Zaimの認証URL
  • GASの実行ログに含まれる認証情報
  • ご自身のZaim口座IDなどの設定値

Consumer KeyとConsumer Secretはコードへ直接書かず、スクリプトプロパティに保存する構成にしています。

まとめ

この仕組みを使えば、JAL Payの決済・チャージ通知をもとにZaimへ自動記帳できます。

特に、

  • 決済は支出として自動登録
  • チャージは自動連携との時間差を考慮して収入に仮登録
  • 利用年月日時刻をメモに残して後からレシートと照合
  • 取引情報を主軸に二重登録を防止
  • 海外通貨は誤登録せずスキップ
  • 初回は7日、通常は1日だけ検索して処理を軽量化
  • 通常20件、手動100件の上限を設けて想定外の一括登録を防止

という点を、実際の家計簿運用に合わせて作っています。

JAL Payは通知メールに店舗名が含まれないため、完全にメンテナンス不要にはできません。それでも、毎回ゼロから手入力するより、日付・時刻・金額まで自動で入った状態から必要な部分だけ直す方が、日々の家計簿管理はかなり楽になります。

今後は、同じ考え方でANA Pay版、楽天Pay版についても別記事で紹介する予定です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

まめぞうアイコン(青右)

まめぞう

「まめぞうの子連れ旅」運営者。
夫婦で訪れた国は、重複を除くと50カ国以上。2024年に国内線中心・9往復・総費用約33.5万円でANA SFCを取得し、現在はJGC入会に必要な1,500LSPを目指しています。
当ブログでは、ANA SFC・JGC/LSP・マイル活用・子連れ旅行を、家族の実体験から発信しています。

目次