【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時間おきのトリガーを想定
 * ・直近1日分のJAL Pay通知を確認
 * ・未処理通知は1回最大20件まで
 *
 * 【初回・手動実行】
 * ・直近7日分を確認
 * ・最大100件まで処理
 *
 * 【記帳ルール】
 * ・JAL Pay決済:支出として登録
 * ・JAL Payチャージ:収入として仮登録
 *   (チャージ元口座の自動連携後、手動で振替へ変更)
 *
 * 【重複防止】
 * ・主判定:種別+利用日時+金額+通貨
 * ・GmailメッセージID:補助判定として内部のみで使用
 * ・内部IDはZaimのメモ欄には表示しない
 */

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日、通常実行は直近1日を確認
const INITIAL_LOOKBACK_DAYS = 7;
const NORMAL_LOOKBACK_DAYS = 1;

// 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;

// 通常実行が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回目以降は直近1日・最大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時間以上経過しているため、" +
      "通常の1日検索では取りこぼす可能性があります。今回は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;

    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: "🤖GAS自動登録 " + transaction.displayDateTime + "(振替待ち)"
        };
      } 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: "🤖GAS自動登録 " + transaction.displayDateTime
        };
      }

      // API登録に成功した取引だけ処理済みにする
      postForm_(service, postUrl, payload);
      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 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・マイル活用・子連れ旅行を、家族の実体験から発信しています。

目次