家計簿アプリ「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日の通知だけを確認して処理を軽量化
という運用にしています。
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側で振替を作ってしまうのではなく、
- ANA Payのチャージ通知を受信
- GASが「収入」として仮登録
- チャージ元口座の取引がZaimへ自動連携
- 仮登録した収入を手動で「振替」に変更
という順番にしています。チャージのメモには、次のように「振替待ち」と残します。
🤖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 Pay | JAL Pay |
|---|---|---|
| 決済日時 | あり | あり |
| 決済金額 | あり | あり |
| 店舗名 | あり | なし |
| Zaimの店舗欄 | 実際の店舗名 | 「JAL Pay決済」 |
| 決済の主な重複判定 | 日時+金額+店舗名+通貨 | 日時+金額+通貨 |
| チャージ取引日時 | 本文になし | あり |
| チャージの主な重複判定 | GmailメッセージID | 取引情報 |
ANA Payは店舗名を取得できるため、自動記帳した時点での完成度はJAL 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へデータを書き込むための「鍵」を発行します。
- Zaim Developers にアクセスし、今回自動記帳したいZaimアカウントでログインします。
- 「新しいアプリケーションを追加」を選択します。
- 以下の内容を入力し、保存します。
| 項目 | 設定 |
|---|---|
| 名称 | ANAPayToZaim(任意の名前でOK) |
| サービス種 | ブラウザアプリ |
| 概要 | ANAPay利用通知をZaimへ自動登録 |
| 組織 | 個人 |
| サービスのURL | ひとまず https://example.com |
| 家計簿の記録を読み込む | チェック |
| 家計簿の記録を書き込む | チェック |
| 家計簿へのアクセスを永続的に許可する | チェック |
| アイコン画像 | 設定不要 |
間違えやすいポイント
「サービス種」はブラウザアプリを選び、アクセスレベルの3項目にはすべてチェックを入れてください。
本記事の自動連携では、Zaimの情報取得・書き込み・定期的な自動実行に使用します。

- 登録したアプリをクリックし、表示される以下の2つを控えます。
- Consumer Key(コンシューマID)
- Consumer Secret(コンシューマシークレット)

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

- 追加後、ライブラリに
OAuth1が追加されていることを確認してください。
コードではこの名前でライブラリを呼び出します。

STEP 3:Zaim Developersの「サービスのURL」を設定する
- GASの「プロジェクトの設定(歯車)」を開き、「スクリプトID」をコピーします。

- 次のURLの
<スクリプトID>部分を、コピーしたIDに置き換えます。https://script.google.com/macros/d/<スクリプトID>/usercallback
- Zaim Developersのアプリ編集画面に戻り、「サービスのURL」 に上記URLを入力して保存します。

STEP 4:スクリプトプロパティを登録してコードを貼り付ける
GASの「プロジェクトの設定」→「スクリプト プロパティを追加」から、STEP 1で取得した2つの値を登録します。
ZAIM_CONSUMER_ID:コンシューマIDZAIM_CONSUMER_SECRET:コンシューマシークレット

続いて「エディタ」に戻り、最初から入っているコードを削除して、以下をすべて貼り付けて保存します。
コード全文を表示する
/**
* ANA Pay → Zaim 自動記帳
*
* 【通常運用】
* ・6時間おきのトリガーを想定
* ・直近2日分のANA Pay通知を確認
* ・未処理通知は1回最大20件まで
*
* 【初回・手動実行】
* ・直近7日分を確認
* ・最大100件まで処理
*
* 【記帳ルール】
* ・ANA Pay決済:支出として登録(店舗名も自動登録)
* ・ANA 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");
const ANA_PAY_SENDER = "payinfo@121.ana.co.jp";
// 初回・手動実行は直近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 = "ANAPAY_INITIALIZED";
const LAST_SUCCESS_AT_KEY = "ANAPAY_LAST_SUCCESS_AT";
// 処理済みトークンは日ごとに保存し、14日より古いものを削除
const DONE_BUCKET_PREFIX = "ANAPAY_DONE_";
const CHARGE_MAP_BUCKET_PREFIX = "ANAPAY_CHARGE_MAP_";
const DONE_TOKEN_RETENTION_DAYS = 14;
// 1実行中のProperties読み込みを減らすためのキャッシュ
const DONE_BUCKET_CACHE = {};
const CHARGE_MAP_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回目以降は直近2日・最大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時間以上経過しているため、" +
"通常の" +
NORMAL_LOOKBACK_DAYS +
"日検索では取りこぼす可能性があります。今回は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;
const zaimMoneyCache = {};
Logger.log(
"今回の登録候補: " +
result.pending.length +
"件"
);
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 (
isChargeTransaction_(
transaction
)
) {
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:
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:
transaction.place,
from_account_id:
settings.anaPaymentId,
comment:
buildExpectedZaimComment_(
transaction
)
};
}
Logger.log(
"登録開始: " +
transaction.displayDateTime +
" " +
transaction.place +
" " +
transaction.amount +
"円"
);
/*
* POST前にZaim側を確認。
*
* 決済:
* 利用日時・金額・店舗等で照合。
*
* チャージ:
* GmailメッセージIDと
* Zaim明細IDの対応を優先する。
*/
const beforePost =
inspectZaimTransaction_(
service,
transaction,
settings,
zaimMoneyCache,
false,
item.mailToken
);
if (
beforePost.status ===
"exact"
) {
if (item.txToken) {
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に同日・同額のANA Pay候補があり、" +
"同一取引か安全に判定できないため自動登録を停止しました: " +
transaction.displayDateTime +
" " +
transaction.place +
" " +
transaction.amount +
"円"
);
}
let postResponse;
try {
postResponse =
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,
item.mailToken,
beforePost.moneyIds
);
} catch (verifyError) {
throw new Error(
"ZaimへのPOST後に例外が発生し、" +
"登録済み確認も完了できませんでした。" +
"二重登録防止のため処理済みにはしていません。" +
" POSTエラー: " +
postError.message +
" / 照合エラー: " +
verifyError.message
);
}
if (verified) {
if (item.txToken) {
rememberDoneTokens_(
item.transactionDateKey,
["T:" + item.txToken]
);
}
rememberDoneTokens_(
item.mailDateKey,
["M:" + item.mailToken]
);
registered++;
Logger.log(
"通信例外は発生しましたがZaimへの登録を確認: " +
transaction.displayDateTime +
" " +
transaction.place +
" " +
transaction.amount +
"円"
);
return;
}
throw postError;
}
/*
* チャージは正常POST後に、
* GmailメールとZaim明細IDを対応付ける。
*/
if (
isChargeTransaction_(
transaction
)
) {
let chargeMoneyId =
extractZaimMoneyIdFromPostResponse_(
postResponse
);
if (!chargeMoneyId) {
try {
chargeMoneyId =
resolveNewChargeMoneyIdAfterSuccessfulPost_(
service,
transaction,
settings,
zaimMoneyCache,
item.mailToken,
beforePost.moneyIds
);
} catch (mappingError) {
Logger.log(
"チャージのZaim明細ID対応付けを確認できませんでした。" +
"今回のメール自体は処理済みにしますが、" +
"同一条件の将来チャージは安全のためambiguousになる場合があります: " +
mappingError.message
);
}
}
if (chargeMoneyId) {
rememberChargeMoneyId_(
transaction.date,
item.mailToken,
chargeMoneyId
);
}
}
if (item.txToken) {
rememberDoneTokens_(
item.transactionDateKey,
["T:" + item.txToken]
);
}
rememberDoneTokens_(
item.mailDateKey,
["M:" + item.mailToken]
);
delete zaimMoneyCache[
transaction.date
];
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;
}
/*
* ここから先は
* 対象のANA Pay通知と確定。
*
* 解析できない場合は
* 黙ってスキップせず停止する。
*/
if (isPayment) {
const dateTime =
parseAnaPaymentDateTime_(
body
);
const amountInfo =
parseAnaAmount_(
body,
"ご利用金額"
);
const place =
parseAnaLineValue_(
body,
"ご利用店舗"
);
if (!dateTime) {
throw new Error(
"ANA Pay対象通知の利用日時を解析できません。" +
"メール形式が変更された可能性があります: " +
message.getSubject()
);
}
if (!amountInfo) {
throw new Error(
"ANA Pay対象通知の利用金額・通貨を解析できません。" +
"メール形式が変更された可能性があります: " +
message.getSubject()
);
}
if (!place) {
throw new Error(
"ANA Pay対象通知の利用店舗を解析できません。" +
"メール形式が変更された可能性があります: " +
message.getSubject()
);
}
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) {
throw new Error(
"ANA Pay対象通知のチャージ金額・通貨を解析できません。" +
"メール形式が変更された可能性があります: " +
message.getSubject()
);
}
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();
// 2026/09/19 12:32:20
// 2026-09-19 12:32:20
let match =
raw.match(
/(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/
);
// 2026年9月19日 12時32分20秒
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})秒)?/
);
}
// 2026年9月19日 12:32:20
if (!match) {
match =
raw.match(
/(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日\s*(\d{1,2}):(\d{1,2})(?::(\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("|");
}
// Zaimに保存するGAS自動登録コメント
function buildExpectedZaimComment_(
transaction
) {
if (
isChargeTransaction_(
transaction
)
) {
return (
"🤖GAS自動登録 " +
transaction.displayDateTime +
"(振替待ち・メール受信日時)"
);
}
return (
"🤖GAS自動登録 " +
transaction.displayDateTime
);
}
function isChargeTransaction_(
transaction
) {
return (
transaction.type ===
"charge" ||
transaction.type ===
"auto_charge"
);
}
// Zaim照合用エラーに
// 再試行可能かの情報を付ける
function createZaimVerifyError_(
message,
retryable
) {
const error =
new Error(
message
);
error.zaimRetryable =
retryable;
return error;
}
/**
* 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は即時停止。
*/
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;
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
);
}
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
);
if (retryable) {
lastError =
httpError;
if (
i <
waits.length - 1
) {
continue;
}
throw httpError;
}
throw httpError;
}
throw (
lastError ||
createZaimVerifyError_(
"Zaim APIから明細を取得できませんでした。",
true
)
);
}
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;
}
function isExactZaimCommentMatch_(
comment,
expectedComment
) {
const actual =
String(
comment || ""
);
return (
actual ===
expectedComment ||
actual.indexOf(
expectedComment +
" "
) === 0 ||
actual.indexOf(
expectedComment +
" "
) === 0
);
}
function isSameAnaPayPlace_(
moneyItem,
transaction
) {
return (
normalizeSpaces_(
moneyItem.place ||
""
).toUpperCase() ===
normalizeSpaces_(
transaction.place ||
""
).toUpperCase()
);
}
function isSameDateAmount_(
moneyItem,
transaction
) {
return (
String(
moneyItem.date ||
""
) ===
String(
transaction.date
) &&
Number(
moneyItem.amount
) ===
Number(
transaction.amount
)
);
}
function getMoneyItemId_(
moneyItem
) {
if (
!moneyItem ||
moneyItem.id == null
) {
return null;
}
return String(
moneyItem.id
);
}
function collectMoneyIds_(
money
) {
return money
.map(
function(moneyItem) {
return getMoneyItemId_(
moneyItem
);
}
)
.filter(
function(id) {
return !!id;
}
);
}
function moneyIdListToSet_(
ids
) {
const set = {};
(
ids || []
).forEach(
function(id) {
set[
String(id)
] = true;
}
);
return set;
}
/**
* 決済の完全一致判定。
*/
function isExactAnaPayPayment_(
moneyItem,
transaction
) {
if (
!isSameDateAmount_(
moneyItem,
transaction
)
) {
return false;
}
if (
!isExactZaimCommentMatch_(
moneyItem.comment,
buildExpectedZaimComment_(
transaction
)
)
) {
return false;
}
return isSameAnaPayPlace_(
moneyItem,
transaction
);
}
function isAmbiguousAnaPayPaymentCandidate_(
moneyItem,
transaction,
settings
) {
if (
!isSameDateAmount_(
moneyItem,
transaction
)
) {
return false;
}
if (
isExactAnaPayPayment_(
moneyItem,
transaction
)
) {
return false;
}
const timeRelation =
compareGASCommentTime_(
moneyItem.comment,
transaction
);
if (
timeRelation ===
"different"
) {
return false;
}
if (
timeRelation ===
"same_or_uncertain" ||
timeRelation ===
"uncertain"
) {
return true;
}
if (
isSameAnaPayPlace_(
moneyItem,
transaction
)
) {
return true;
}
if (
Number(
moneyItem.from_account_id
) ===
Number(
settings.anaPaymentId
) ||
Number(
moneyItem.to_account_id
) ===
Number(
settings.anaPaymentId
)
) {
return true;
}
return false;
}
/**
* チャージ候補判定。
*
* チャージはGmailメッセージIDが主判定。
* 日時・金額・コメント等だけでは
* exactにしない。
*/
function isAmbiguousAnaPayChargeCandidate_(
moneyItem,
transaction,
settings,
mailToken
) {
if (
!isSameDateAmount_(
moneyItem,
transaction
)
) {
return false;
}
const moneyId =
getMoneyItemId_(
moneyItem
);
if (moneyId) {
const mappedMailToken =
getChargeMailTokenForMoneyId_(
transaction.date,
moneyId
);
/*
* このZaim明細が
* 別のチャージメールに対応済みなら、
* 今回のメールの候補ではない。
*/
if (
mappedMailToken &&
mappedMailToken !==
mailToken
) {
return false;
}
}
const timeRelation =
compareGASCommentTime_(
moneyItem.comment,
transaction
);
if (
timeRelation ===
"different"
) {
return false;
}
if (
timeRelation ===
"same_or_uncertain" ||
timeRelation ===
"uncertain"
) {
return true;
}
if (
isSameAnaPayPlace_(
moneyItem,
transaction
)
) {
return true;
}
if (
Number(
moneyItem.from_account_id
) ===
Number(
settings.anaPaymentId
) ||
Number(
moneyItem.to_account_id
) ===
Number(
settings.anaPaymentId
)
) {
return true;
}
return false;
}
/**
* Zaim上で今回の取引を照合。
*
* チャージ:
* mailToken ↔ Zaim明細IDが一致した場合だけexact。
*
* 日時・金額等だけで似ている場合は
* ambiguousとして止める。
*/
function inspectZaimTransaction_(
service,
transaction,
settings,
cache,
forceRefresh,
mailToken
) {
const money =
loadZaimMoneyForDate_(
service,
transaction.date,
cache,
forceRefresh
);
const moneyIds =
collectMoneyIds_(
money
);
if (
isChargeTransaction_(
transaction
)
) {
const mappedMoneyId =
getChargeMoneyId_(
transaction.date,
mailToken
);
if (mappedMoneyId) {
const mappedExists =
money.some(
function(moneyItem) {
return (
getMoneyItemId_(
moneyItem
) ===
String(
mappedMoneyId
)
);
}
);
if (mappedExists) {
return {
status:
"exact",
count:
1,
moneyIds:
moneyIds
};
}
/*
* 対応情報はあるが、
* Zaim明細が当日のGET結果にない。
* 再登録せず安全側で停止。
*/
return {
status:
"ambiguous",
count:
1,
moneyIds:
moneyIds
};
}
const ambiguousChargeMatches =
money.filter(
function(moneyItem) {
return (
isAmbiguousAnaPayChargeCandidate_(
moneyItem,
transaction,
settings,
mailToken
)
);
}
);
if (
ambiguousChargeMatches.length >
0
) {
return {
status:
"ambiguous",
count:
ambiguousChargeMatches.length,
moneyIds:
moneyIds
};
}
return {
status:
"missing",
count:
0,
moneyIds:
moneyIds
};
}
const exactMatches =
money.filter(
function(moneyItem) {
return (
isExactAnaPayPayment_(
moneyItem,
transaction
)
);
}
);
if (
exactMatches.length >
0
) {
return {
status:
"exact",
count:
exactMatches.length,
moneyIds:
moneyIds
};
}
const ambiguousMatches =
money.filter(
function(moneyItem) {
return (
isAmbiguousAnaPayPaymentCandidate_(
moneyItem,
transaction,
settings
)
);
}
);
if (
ambiguousMatches.length >
0
) {
return {
status:
"ambiguous",
count:
ambiguousMatches.length,
moneyIds:
moneyIds
};
}
return {
status:
"missing",
count:
0,
moneyIds:
moneyIds
};
}
/**
* POSTレスポンスに明細IDが含まれていれば利用する。
* 取得できない場合はnull。
*/
function extractZaimMoneyIdFromPostResponse_(
response
) {
if (!response) {
return null;
}
try {
const body =
response.getContentText();
if (!body) {
return null;
}
const data =
JSON.parse(
body
);
if (
data &&
data.money &&
data.money.id != null
) {
return String(
data.money.id
);
}
if (
data &&
data.id != null
) {
return String(
data.id
);
}
} catch (e) {
return null;
}
return null;
}
/**
* POST直後に作られた
* 今回のチャージ明細として
* 十分一致するか確認。
*/
function isFreshChargeMatch_(
moneyItem,
transaction
) {
return (
isSameDateAmount_(
moneyItem,
transaction
) &&
isExactZaimCommentMatch_(
moneyItem.comment,
buildExpectedZaimComment_(
transaction
)
) &&
isSameAnaPayPlace_(
moneyItem,
transaction
)
);
}
/**
* POST前には存在しなかったZaim明細のうち、
* 今回のチャージ内容に一致するIDを取得。
*/
function findNewChargeMoneyIds_(
money,
transaction,
beforeMoneyIds,
mailToken
) {
const beforeSet =
moneyIdListToSet_(
beforeMoneyIds
);
return money
.filter(
function(moneyItem) {
if (
!isFreshChargeMatch_(
moneyItem,
transaction
)
) {
return false;
}
const moneyId =
getMoneyItemId_(
moneyItem
);
if (
!moneyId ||
beforeSet[
moneyId
]
) {
return false;
}
const mappedMailToken =
getChargeMailTokenForMoneyId_(
transaction.date,
moneyId
);
return (
!mappedMailToken ||
mappedMailToken ===
mailToken
);
}
)
.map(
function(moneyItem) {
return getMoneyItemId_(
moneyItem
);
}
);
}
/**
* 正常POST後、
* POSTレスポンスからIDを取れなかった場合に、
* POST前後のZaim明細ID差分から
* 今回のチャージIDを探す。
*/
function resolveNewChargeMoneyIdAfterSuccessfulPost_(
service,
transaction,
settings,
cache,
mailToken,
beforeMoneyIds
) {
const waits = [
0,
1000,
3000
];
for (
let i = 0;
i < waits.length;
i++
) {
if (
waits[i] > 0
) {
Utilities.sleep(
waits[i]
);
}
const money =
loadZaimMoneyForDate_(
service,
transaction.date,
cache,
true
);
const newIds =
findNewChargeMoneyIds_(
money,
transaction,
beforeMoneyIds,
mailToken
);
if (
newIds.length === 1
) {
return newIds[0];
}
if (
newIds.length > 1
) {
throw createZaimVerifyError_(
"POST後に新しいチャージ候補が複数見つかり、" +
"Zaim明細IDを一意に特定できません。",
false
);
}
}
return null;
}
/**
* POSTで例外が出た場合の登録確認。
*
* 決済:
* exact照合で登録済みを確認。
*
* チャージ:
* 日時・金額だけでは成功扱いにしない。
* POST前には存在しなかったZaim明細IDが
* POST後に1件だけ増え、その内容が一致した場合だけ
* mailToken↔Zaim明細IDを保存して成功扱いにする。
*/
function verifyZaimRegistrationAfterPostError_(
service,
transaction,
settings,
cache,
mailToken,
beforeMoneyIds
) {
const waits = [
1500,
3000,
6000
];
let sawAmbiguous =
false;
let lastVerifyError =
null;
for (
let i = 0;
i < waits.length;
i++
) {
Utilities.sleep(
waits[i]
);
try {
if (
isChargeTransaction_(
transaction
)
) {
const money =
loadZaimMoneyForDate_(
service,
transaction.date,
cache,
true
);
const newIds =
findNewChargeMoneyIds_(
money,
transaction,
beforeMoneyIds,
mailToken
);
if (
newIds.length === 1
) {
rememberChargeMoneyId_(
transaction.date,
mailToken,
newIds[0]
);
return true;
}
if (
newIds.length > 1
) {
throw createZaimVerifyError_(
"POST後に新しいチャージ候補が複数見つかり、" +
"同一取引を確定できません。",
false
);
}
const result =
inspectZaimTransaction_(
service,
transaction,
settings,
cache,
false,
mailToken
);
if (
result.status ===
"ambiguous"
) {
sawAmbiguous =
true;
Logger.log(
"Zaim登録確認 " +
(i + 1) +
"/" +
waits.length +
": チャージ候補はありますが、" +
"メールIDとZaim明細IDの対応を確定できません"
);
} else {
Logger.log(
"Zaim登録確認 " +
(i + 1) +
"/" +
waits.length +
": まだ今回のチャージ登録を確認できません"
);
}
} else {
const result =
inspectZaimTransaction_(
service,
transaction,
settings,
cache,
true,
mailToken
);
if (
result.status ===
"exact"
) {
return true;
}
if (
result.status ===
"ambiguous"
) {
sawAmbiguous =
true;
Logger.log(
"Zaim登録確認 " +
(i + 1) +
"/" +
waits.length +
": 同日・同額のANA Pay候補があり、まだ確定できません"
);
} else {
Logger.log(
"Zaim登録確認 " +
(i + 1) +
"/" +
waits.length +
": まだ同一取引を確認できません"
);
}
}
} catch (verifyError) {
if (
verifyError &&
verifyError.zaimRetryable ===
false
) {
throw verifyError;
}
lastVerifyError =
verifyError;
Logger.log(
"Zaim登録確認 " +
(i + 1) +
"/" +
waits.length +
" に失敗: " +
verifyError.message
);
}
}
if (sawAmbiguous) {
throw createZaimVerifyError_(
"POST後のZaim確認で候補は見つかりましたが、" +
"同一取引か確定できませんでした。" +
"自動再登録は行いません。",
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
);
}
/**
* チャージメールと
* Zaim明細IDの対応情報。
*/
function chargeMapBucketPropertyKey_(
dateKey
) {
return (
CHARGE_MAP_BUCKET_PREFIX +
dateKey.replace(
/-/g,
""
)
);
}
function loadChargeMapBucket_(
dateKey
) {
const propertyKey =
chargeMapBucketPropertyKey_(
dateKey
);
if (
CHARGE_MAP_BUCKET_CACHE[
propertyKey
]
) {
return (
CHARGE_MAP_BUCKET_CACHE[
propertyKey
]
);
}
const raw =
USER_PROPS.getProperty(
propertyKey
);
let map = {};
if (raw) {
try {
const parsed =
JSON.parse(
raw
);
if (
parsed &&
typeof parsed ===
"object" &&
!Array.isArray(
parsed
)
) {
map =
parsed;
}
} catch (e) {
map = {};
}
}
const bucket = {
propertyKey:
propertyKey,
map:
map
};
CHARGE_MAP_BUCKET_CACHE[
propertyKey
] = bucket;
return bucket;
}
function getChargeMoneyId_(
dateKey,
mailToken
) {
const bucket =
loadChargeMapBucket_(
dateKey
);
const value =
bucket.map[
mailToken
];
if (
value == null ||
value === ""
) {
return null;
}
return String(
value
);
}
function rememberChargeMoneyId_(
dateKey,
mailToken,
moneyId
) {
if (
!mailToken ||
moneyId == null
) {
return;
}
const bucket =
loadChargeMapBucket_(
dateKey
);
const normalizedId =
String(
moneyId
);
if (
bucket.map[
mailToken
] ===
normalizedId
) {
return;
}
bucket.map[
mailToken
] = normalizedId;
USER_PROPS.setProperty(
bucket.propertyKey,
JSON.stringify(
bucket.map
)
);
}
function getChargeMailTokenForMoneyId_(
dateKey,
moneyId
) {
if (
moneyId == null
) {
return null;
}
const targetId =
String(
moneyId
);
const bucket =
loadChargeMapBucket_(
dateKey
);
const mailTokens =
Object.keys(
bucket.map
);
for (
let i = 0;
i < mailTokens.length;
i++
) {
const mailToken =
mailTokens[i];
if (
String(
bucket.map[
mailToken
]
) ===
targetId
) {
return mailToken;
}
}
return null;
}
// 処理済み情報
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
)
);
}
}
/**
* 処理済みトークンと
* チャージID対応情報を
* 14日経過後に削除。
*/
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) {
let prefix =
null;
if (
key.indexOf(
DONE_BUCKET_PREFIX
) === 0
) {
prefix =
DONE_BUCKET_PREFIX;
} else if (
key.indexOf(
CHARGE_MAP_BUCKET_PREFIX
) === 0
) {
prefix =
CHARGE_MAP_BUCKET_PREFIX;
} else {
return;
}
const datePart =
key.substring(
prefix.length
);
if (
/^\d{8}$/.test(
datePart
) &&
datePart <
cutoffKey
) {
USER_PROPS.deleteProperty(
key
);
delete DONE_BUCKET_CACHE[
key
];
delete CHARGE_MAP_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の初回認証
- GAS画面上部の関数選択から
anaPayToZaimを選び、「実行」をクリックします。

- 初回実行時はGoogleアカウントへのアクセス許可を求められるので、今回GASを作成したGoogleアカウントを選択します。
- 「このアプリは Google で確認されていません」 と表示された場合は、画面下部の 「詳細」 をクリックします。
- 続いて 「ANAPayToZaim(安全ではないページ)に移動」 などと表示されるリンクをクリックします。

- 表示されるアクセス権限の内容を確認します。
本スクリプトでは、ANA Payから届く利用通知メールをGmailから検索・読み取るため、Gmailへのアクセス権限が必要です。Googleの許可画面では、Gmailに関する比較的広い権限が表示される場合がありますが、本記事のスクリプトではANA Pay通知メールの検索・読み取りに使用します。内容を確認し、問題なければ 「次へ」 をクリックします。
※アクセス権限を許可した後、Googleから「アカウントへのアクセスを許可した」旨のセキュリティ通知メールが届く場合があります。自分で作成したANAPayToZaimへの許可であれば問題ありません。 - その後、GASの実行ログに表示されたZaim認証用URLを開き、Zaim側でもアクセスを許可します。
- 認証が成功すると、「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のどの口座・カテゴリーに記帳するかを設定します。
- GAS画面上部の関数一覧から
checkZaimIdsを選択して「実行」します。

- 画面下部の実行ログに、現在のZaimアカウントに登録されている以下のIDが表示されます。
- 口座
- 支出カテゴリー
- 収入カテゴリー
- ジャンル(内訳)
- ログを確認し、使用したい項目の名前の右側に表示されている数字(ID)を控えます。
- GAS画面左側の 歯車アイコン「プロジェクトの設定」 を開きます。
- 画面を下へスクロールし、「スクリプト プロパティ」 を開きます。
- 「スクリプト プロパティを追加」 をクリックします。
- 「プロパティ」と「値」に、先ほど確認したIDをそれぞれ入力し、「スクリプト プロパティを保存」 をクリックします。
例えば、実行ログが次のように表示されたとします。
=== 口座(入出金元) ID一覧 ===
ANA Pay: 12345
=== 収入カテゴリー ID一覧 ===
その他: 201
=== 支出カテゴリー ID一覧 ===
その他: 101
=== ジャンル(内訳) ID一覧 ===
[その他] 未分類: 1001
この場合、「プロジェクトの設定」→「スクリプト プロパティ」に次のように登録します。
| プロパティ名 | 入力する値 | 役割 |
|---|---|---|
ANA_PAYMENT_ID | 12345 | 事前に作成したZaimの「ANA Pay」口座のID |
DEFAULT_INCOME_CATEGORY_ID | 201 | チャージを一時的に収入として登録する際のカテゴリーID |
DEFAULT_CATEGORY_ID | 101 | ANA Pay決済を登録する支出カテゴリーのID |
DEFAULT_GENRE_ID | 1001 | 上記カテゴリー内で使用するジャンルの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へ取り込みたい場合
- GAS画面上部の関数一覧から
anaPayToZaimを選択し、「実行」をクリックします。 - 初回の記帳実行では、直近7日間のANA Pay通知を確認し、最大100件までまとめて登録します。
- 実行ログの最後に、
処理完了: 登録 8件 / スキップ 0件のように表示されれば正常に動作しています。 - 実行が完了したらZaimを開き、ANA Payの決済・チャージが想定どおり登録されていることが確認できれば完了です。
STEP 8:6時間おきに自動実行する
最後に、ANA Payの通知を定期的に確認するため、GASのトリガーを設定します。
- GAS画面左側の 「トリガー」(時計アイコン)を開きます。
- 右下の 「トリガーを追加」 をクリックします。
- 以下のように設定します。
- 実行する関数:
anaPayToZaim - 実行するデプロイ:
Head - イベントのソース:時間主導型
- 時間ベースのトリガーのタイプ:時間ベースのタイマー
- 時間の間隔:6時間おき
- エラー通知設定:今すぐ通知を受け取る

anaPayToZaim を6時間おきに自動実行します。設定できたら、右下の 「保存」 をクリックします。
これで、以降は6時間おきにGASが自動実行され、ANA Payの通知メールを確認してZaimへ記帳します。
通常実行では直近1日分の通知を確認し、未処理のものだけを登録するため、同じメールを繰り返し確認しても二重登録されないようにしています。
これでセットアップは完了です。
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についても自動記帳できるか検証していく予定です。

