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

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

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

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

この方法では、

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

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

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

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

目次

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

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

ANA Payから届く「ご利用のお知らせ」メールをGASが読み取り、利用日・利用金額・利用店舗をZaimへ自動登録します。

たとえば通知メールが、

ご利用日時:2026-09-10 12:34:56
ご利用金額:1,234円
ご利用店舗:○○ストア

であれば、Zaimには次のように登録します。

日付:2026/09/10
金額:1,234円
店舗:○○ストア
メモ:🤖GAS自動登録 2026/09/10 12:34:56

JAL Pay版との大きな違いは、店舗名まで自動で入れられることです。

JAL Payの利用通知には店舗名が含まれないため後から修正が必要でしたが、ANA Payでは多くの決済をそのまま家計簿として使える状態まで自動化できます。

2.通常チャージ・オートチャージはあえて「収入」として仮登録

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

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

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

🤖GAS自動登録 2026/09/13 07:20:46(振替待ち・メール受信日時)

チャージ日時は「メール受信日時」を使用

ANA Payのチャージ通知には、

チャージ金額:10,000円

のように金額は記載されていますが、チャージした取引日時は本文に記載されていません

そのため、このスクリプトではチャージについてのみ、Gmailが受信した日時をZaimの日付・メモに使用します。

通常チャージは「ANA Payチャージ」、オートチャージは「ANA Payオートチャージ」として区別します。

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

3.決済は取引情報、チャージはメールIDで重複判定

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

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

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

決済については、「利用年月日時刻・金額・店舗名・通貨」を組み合わせて同一取引を判定し、GmailのメッセージIDも補助的に利用します。
一方、通常チャージ・オートチャージの通知には取引日時が記載されていないため、チャージについてはGmailのメッセージIDを主な重複判定に使用します。ただし、メッセージIDはGAS内部でハッシュ化して保存するだけで、Zaimのメモ欄には表示しません。

処理済み情報について
二重登録防止のための処理済み情報は、このGASプロジェクト内のユーザープロパティに保存されます。そのため、「GASで自動登録した履歴を後からレシート取込の履歴へ置き換える」、「GASのメモだけ新しい履歴へコピーする」、「Zaim上で店舗名やカテゴリーを修正する」といった操作をしても、元のANA Pay通知が再び自動登録されることはありません。
一方で、Zaim側の履歴を削除してもGAS側では処理済みのままなので、誤って削除した場合に自動復元される仕組みではない点には注意してください。
また、GASプロジェクトを作り直したり、処理済み情報を削除した場合は、直近の取引が再登録される可能性があるためご注意ください。

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

通知メールの仕様変更などによりJPY以外の通貨表記を検出した場合、金額をそのまま「円」として誤登録しないよう、本スクリプトでは自動登録をスキップします。JPY以外としてスキップされた取引は、後日利用明細で日本円換算額を確認してから手動で登録してください。

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

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

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

6. JAL Pay版との違い

JAL Pay版と基本的な考え方は同じですが、通知メールの内容が異なるため、重複判定などはANA Pay専用にしています。

項目ANA PayJAL Pay
決済日時ありあり
決済金額ありあり
店舗名ありなし
Zaimの店舗欄実際の店舗名「JAL Pay決済」
決済の主な重複判定日時+金額+店舗名+通貨日時+金額+通貨
チャージ取引日時本文になしあり
チャージの主な重複判定GmailメッセージID取引情報

ANA Payは店舗名を取得できるため、自動記帳した時点での完成度はJAL Pay版より高いのがメリットです。

事前準備

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

JAL Pay版を導入済みの方へ
ANA Pay版は、JAL Pay版とは別のGoogle Apps Scriptプロジェクト、別のZaim Developersアプリとして作成してください。Consumer Key / Consumer SecretもANA Pay用に新しく発行したものを使用します。

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

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

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

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

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

  1. Zaim Developers にアクセスし、今回自動記帳したいZaimアカウントでログインします。
  2. 「新しいアプリケーションを追加」を選択します。
  3. 以下の内容を入力し、保存します。
項目設定
名称ANAPayToZaim(任意の名前でOK)
サービス種ブラウザアプリ
概要ANAPay利用通知を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. 画面左上の「無題のプロジェクト」をクリックし、分かりやすい名前(例:ANAPayToZaim)を付けます。
    • プロジェクト名について
      Zaim Developersのアプリ名とGASプロジェクト名は、動作上は同じである必要はありません。
      本記事では分かりやすさのため、どちらも ANAPayToZaim に統一します。
  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月時点)※画面構成や表記は変更される場合があります。

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

コード全文を表示する
/**
 * ANA Pay → Zaim 自動記帳
 *
 * 【通常運用】
 * ・6時間おきのトリガーを想定
 * ・直近1日分のANA Pay通知を確認
 * ・未処理通知は1回最大20件まで
 *
 * 【初回・手動実行】
 * ・直近7日分を確認
 * ・最大100件まで処理
 *
 * 【記帳ルール】
 * ・ANA Pay決済:支出として登録(店舗名も自動登録)
 * ・ANA 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");

const ANA_PAY_SENDER = "payinfo@121.ana.co.jp";

// 初回・手動実行は直近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 = "ANAPAY_INITIALIZED";
const LAST_SUCCESS_AT_KEY = "ANAPAY_LAST_SUCCESS_AT";

// 処理済みトークンは日ごとに保存し、14日より古いものを削除
const DONE_BUCKET_PREFIX = "ANAPAY_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("まず anaPayToZaim を実行して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 anaPayToZaim() {
  const initialized = USER_PROPS.getProperty(INITIALIZED_KEY) === "1";

  if (!initialized) {
    runAnaPaySync_(
      "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(
      "ANA Payの自動登録を保留しました。前回の正常終了から24時間以上経過しているため、" +
      "通常の1日検索では取りこぼす可能性があります。今回はZaimへ1件も登録していません。" +
      "Google Apps Scriptを開き、関数 anaPayToZaimManual を1回実行してください。"
    );
  }

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

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

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

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

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

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

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

    result.duplicateMailMarks.forEach(function(mark) {
      rememberDoneTokens_(mark.dateKey, [mark.token]);
    });

    result.pending.forEach(function(item) {
      if (item.txToken) {
        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 + "日分のANA Pay通知 " +
      result.pending.length +
      "件を処理済みにしました。Zaimへの登録は行っていません。"
    );
  } finally {
    lock.releaseLock();
  }
}

function runAnaPaySync_(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);

    const result = collectPendingAnaPayMessages_(
      lookbackDays,
      maxNewMessages,
      maxScannedMessages
    );

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

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

    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;

      if (transaction.currency !== "JPY") {
        Logger.log(
          "日本円以外のため自動登録をスキップ: " +
          transaction.displayDateTime + " " +
          transaction.amount + " " + transaction.currency
        );

        if (item.txToken) {
          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" ||
        transaction.type === "auto_charge"
      ) {
        postUrl = "https://api.zaim.net/v2/home/money/income";
        payload = {
          mapping: 1,
          category_id: settings.incomeCategoryId,
          amount: transaction.amount,
          date: transaction.date,
          place: transaction.place,
          to_account_id: settings.anaPaymentId,
          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: transaction.place,
          from_account_id: settings.anaPaymentId,
          comment: "🤖GAS自動登録 " + transaction.displayDateTime
        };
      }

      postForm_(service, postUrl, payload);

      if (item.txToken) {
        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(
      "ANA Payの自動登録を保留しました。未処理のANA Pay通知が21件以上あり、" +
      "通常実行の上限20件を超えています。今回はZaimへ1件も登録していません。" +
      "Google Apps Scriptを開き、関数 anaPayToZaimManual を1回実行してください。" +
      "手動実行では最大100件まで処理できます。"
    );
  }

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

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

function getZaimSettings_() {
  return {
    anaPaymentId: getNumberProperty_("ANA_PAYMENT_ID"),
    incomeCategoryId: getNumberProperty_("DEFAULT_INCOME_CATEGORY_ID"),
    paymentCategoryId: getNumberProperty_("DEFAULT_CATEGORY_ID"),
    paymentGenreId: getNumberProperty_("DEFAULT_GENRE_ID")
  };
}

function validateZaimSettings_(settings) {
  if (!settings.anaPaymentId) {
    throw new Error("ANA_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 が未設定です。"
    );
  }
}

function collectPendingAnaPayMessages_(
  lookbackDays,
  maxNewMessages,
  maxScannedMessages
) {
  const query =
    "from:" + ANA_PAY_SENDER +
    " newer_than:" + lookbackDays + "d";

  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 = parseAnaPayMessage_(message);
        if (!transaction) {
          continue;
        }

        const mailToken = shortHash_("MAIL|" + messageId);
        const mailDateKey = formatDateKey_(message.getDate());

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

        let txToken = null;
        const transactionDateKey = transaction.date;

        if (transaction.type === "payment") {
          txToken = shortHash_(
            "TX|" + buildPaymentTransactionKey_(transaction)
          );

          if (
            hasDoneToken_(transactionDateKey, "T:" + txToken) ||
            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
        });

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

function parseAnaPayMessage_(message) {
  const subject = normalizeSpaces_(message.getSubject());
  const body = message.getPlainBody();
  const from = extractEmailAddress_(message.getFrom());

  if (from !== ANA_PAY_SENDER) {
    return null;
  }

  const isAnaPay = subject.indexOf("ANA Pay") !== -1;
  if (!isAnaPay) {
    return null;
  }

  const isPayment =
    subject.indexOf("ご利用のお知らせ") !== -1;

  const isAutoCharge =
    subject.indexOf("オートチャージ完了のお知らせ") !== -1;

  const isCharge =
    !isAutoCharge &&
    subject.indexOf("チャージ完了のお知らせ") !== -1;

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

  if (isPayment) {
    const dateTime = parseAnaPaymentDateTime_(body);
    const amountInfo = parseAnaAmount_(body, "ご利用金額");
    const place = parseAnaLineValue_(body, "ご利用店舗");

    if (!dateTime) {
      Logger.log(
        "ANA Payの利用日時を解析できません: " + message.getSubject()
      );
      return null;
    }

    if (!amountInfo) {
      Logger.log(
        "ANA Payの利用金額・通貨を解析できません: " +
        message.getSubject()
      );
      return null;
    }

    if (!place) {
      Logger.log(
        "ANA Payの利用店舗を解析できません: " + message.getSubject()
      );
      return null;
    }

    return {
      source: "ANA_PAY",
      type: "payment",
      date: dateTime.date,
      isoDateTime: dateTime.isoDateTime,
      displayDateTime: dateTime.displayDateTime,
      amount: amountInfo.amount,
      currency: amountInfo.currency,
      rawAmount: amountInfo.raw,
      place: place
    };
  }

  const amountInfo = parseAnaAmount_(body, "チャージ金額");
  const receivedDateTime = formatDateTimeFromDate_(message.getDate());

  if (!amountInfo) {
    Logger.log(
      "ANA Payのチャージ金額・通貨を解析できません: " +
      message.getSubject()
    );
    return null;
  }

  return {
    source: "ANA_PAY",
    type: isAutoCharge ? "auto_charge" : "charge",
    date: receivedDateTime.date,
    isoDateTime: receivedDateTime.isoDateTime,
    displayDateTime: receivedDateTime.displayDateTime,
    amount: amountInfo.amount,
    currency: amountInfo.currency,
    rawAmount: amountInfo.raw,
    place: isAutoCharge
      ? "ANA Payオートチャージ"
      : "ANA Payチャージ"
  };
}

function parseAnaPaymentDateTime_(body) {
  const lineMatch = body.match(
    /ご利用日時[::]\s*([^\r\n]+)/
  );

  if (!lineMatch) {
    return null;
  }

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

  let match = raw.match(
    /(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/
  );

  if (!match) {
    match = raw.match(
      /(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日\s*(\d{1,2})時\s*(\d{1,2})分(?:\s*(\d{1,2})秒)?/
    );
  }

  if (!match) {
    return null;
  }

  const year = String(match[1]);
  const month = pad2_(match[2]);
  const day = pad2_(match[3]);
  const hour = pad2_(match[4]);
  const minute = pad2_(match[5]);
  const second = match[6] ? pad2_(match[6]) : 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 parseAnaAmount_(body, label) {
  const regexp = new RegExp(
    escapeRegExp_(label) + "[::]\\s*([^\\r\\n]+)"
  );

  const match = body.match(regexp);
  if (!match) {
    return null;
  }

  const raw = normalizeSpaces_(match[1]);
  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 parseAnaLineValue_(body, label) {
  const regexp = new RegExp(
    escapeRegExp_(label) + "[::]\\s*([^\\r\\n]+)"
  );

  const match = body.match(regexp);
  if (!match) {
    return null;
  }

  const value = normalizeSpaces_(match[1]);
  return value || null;
}

function buildPaymentTransactionKey_(transaction) {
  return [
    transaction.source,
    transaction.type,
    transaction.isoDateTime,
    String(transaction.amount),
    transaction.currency,
    normalizeSpaces_(transaction.place).toUpperCase()
  ].join("|");
}

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

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"
  );
}

function formatDateTimeFromDate_(date) {
  return {
    date: Utilities.formatDate(
      date,
      Session.getScriptTimeZone(),
      "yyyy-MM-dd"
    ),
    isoDateTime: Utilities.formatDate(
      date,
      Session.getScriptTimeZone(),
      "yyyy-MM-dd'T'HH:mm:ss"
    ),
    displayDateTime: Utilities.formatDate(
      date,
      Session.getScriptTimeZone(),
      "yyyy/MM/dd HH:mm:ss"
    )
  };
}

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 extractEmailAddress_(from) {
  const match = String(from || "").match(/<([^>]+)>/);
  return (match ? match[1] : String(from || ""))
    .trim()
    .toLowerCase();
}

function normalizeSpaces_(text) {
  return String(text || "")
    .replace(/\u00a0/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

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

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

STEP 5:Zaimの初回認証

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

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

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

Gmailの権限について
このスクリプトはANA 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一覧 ===
ANA Pay: 12345
=== 収入カテゴリー ID一覧 ===
その他: 201
=== 支出カテゴリー ID一覧 ===
その他: 101
=== ジャンル(内訳) ID一覧 ===
[その他] 未分類: 1001

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

セキュリティ上の注意

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

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

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

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

まとめ

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

特に、

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

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

ANA Payは通知メールに店舗名が含まれるため、JAL Pay版と比べて自動記帳した時点で家計簿として完成度が高いのがメリットです。決済については日付・時刻・金額・店舗名まで自動で入るため、日々の手入力をかなり減らせます。

今後は、同じ考え方で楽天Payについても自動記帳できるか検証していく予定です。

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

この記事を書いた人

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

まめぞう

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

目次