前回の記事で、Gmailの領収書メールをGoogle DriveにPDF保存する方法を書きました。
GASでGmailの領収書メールをGoogle Driveに自動保存する方法
今回はその続きで、Driveに保存したPDFをGASでfreeeファイルボックスに自動アップロードする方法を書きます。
freeeのファイルボックスを普段から使っている方向けの、経理効率化ネタです。

DriveにPDFを保存しても、freeeへの登録が別作業として残る
前回の記事で、領収書メールをGoogle DriveにPDF保存するところまでは自動化できました。
ただ、普段からScanSnapなどで紙のレシートをfreeeのファイルボックスに送っている方なら、Driveに保存しただけでは少し足りません。
今度はそのPDFをfreeeファイルボックスに登録する作業が残るからです。
せっかくなのでfreeeへ送るところまでやってしまいます。
GASでDriveからfreeeファイルボックスに自動アップロードするやり方
DriveのPDFをfreee APIでファイルボックスに送ります。
最初にfreee側でAPI利用の設定が必要です。
事前準備:freee APIの設定
freeeとGASを連携するには、freee Developersでアプリを作成します。
1. freeeのAPIアプリを登録する
freee Developersからアプリを作成します。
作成すると、クライアントIDとクライアントシークレットが発行されます。


2. コールバックURLを設定する
後述のコードを貼り付けて authorize を実行すると、ログに認可用のURLが出ます。
そのURLに含まれるコールバックURLを、freee側のリダイレクトURIに設定します。
authorize を実行するとログに認可URLが出ます。
その中の redirect_uri= の直後から &state= の手前までをコピーして、以下のように置き換えてください。
%3A→:%2F→/
redirect_uri=https%3A%2F%2Fscript.google.com%2Fmacros%2Fd%2F{スクリプトID}%2Fusercallback
置き換えるとこういう形になります。
https://script.google.com%2Fmacros%2Fd%2F{スクリプトID}%2Fusercallback
このURLをfreeeのアプリ詳細のコールバックURLに貼ります。

3. ファイルボックス関連の権限を有効にする
権限設定で、ファイルボックス関連の権限を有効にします。


4. OAuth2ライブラリを追加する
GASのライブラリに、OAuth2ライブラリを追加します。


ライブラリIDは以下です。
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF
設定は最初の1回だけです。
コードのポイント
他のサービスを足したいときは、ここに1行追加すれば足ります。
TARGETS
const TARGETS = [
{
name: 'starbucks',
query: 'subject:"Mobile Order & Pay"',
folder: '領収書/starbucks',
},
];前回と同じです。
他のサービスを追加したいときは、ここに1行追加するだけです。
FREEE_COMPANY_ID
const FREEE_COMPANY_ID = 'ここに事業所IDを入れる';getCompanyId を実行すると、ログに事業所IDが出ます。
認証情報の設定
クライアントIDとシークレットは、コードに直書きしないほうが安全です。setFreeeCredentials を使って Script Properties に保存します。
function setFreeeCredentials() {
const props = PropertiesService.getScriptProperties();
props.setProperty('FREEE_CLIENT_ID', 'ここにクライアントIDを入れる');
props.setProperty('FREEE_CLIENT_SECRET', 'ここにシークレットを入れる');
Logger.log('設定完了');
}この関数は初回設定用です。
実行後は削除しておくほうが安心でしょう。
設定手順
流れとしては、次の順番で進めると分かりやすいです。
- Googleドライブでスプレッドシートを1つ作成し、Apps Scriptを開く
- OAuth2ライブラリを追加する
- 下記のコードを貼り付けて保存する
setFreeeCredentialsにIDとシークレットを入れて実行するauthorizeを実行して、ログに出た認可URLをブラウザで開く- freee側で許可する
saveAllReceiptsを実行してDriveにPDFを保存するuploadDrivePdfsToFreeeを実行してfreeeにアップロードする- 問題なければ
weeklyJobを週1の時間主導トリガーで設定する
最初はトリガーより手動で動作確認したほうがいいです。
まずDriveに保存できるか。
次にfreeeへ送れるか。
DriveへのPDF保存とfreeeへのアップロード、この2つを分けて確認するほうが原因を特定しやすいです。
コード全文
コード
// ============================================================
// ここだけ編集すれば他のサービスにも使えます
// ============================================================
const TARGETS = [
{
name: 'starbucks',
query: 'subject:"Mobile Order & Pay"',
folder: '領収書/starbucks',
},
// 追加例
// {
// name: 'amazon',
// query: 'subject:"Amazonからの注文確認"',
// folder: '領収書/amazon',
// },
];
const FREEE_COMPANY_ID = 'ここに事業所IDを入れる';
const FREEE_CLIENT_ID =
PropertiesService.getScriptProperties().getProperty('FREEE_CLIENT_ID');
const FREEE_CLIENT_SECRET =
PropertiesService.getScriptProperties().getProperty('FREEE_CLIENT_SECRET');
// ============================================================
// 初回だけ実行(過去分をまとめて保存)
// ============================================================
function saveAllReceipts() {
saveReceipts(target => getAllThreads(target.query));
}
// ============================================================
// 週1トリガーに設定する(直近14日分だけ確認)
// ============================================================
function saveRecentReceipts() {
saveReceipts(target => getRecentThreads(target.query, 14));
}
// ============================================================
// 週1トリガー:PDF保存 → freeeアップロードを一括実行
// ============================================================
function weeklyJob() {
saveRecentReceipts();
uploadDrivePdfsToFreee();
}
// ============================================================
// 共通処理
// ============================================================
function saveReceipts(getThreadsFn) {
TARGETS.forEach(target => {
const folder = getOrCreateFolder(target.folder);
const threads = getThreadsFn(target);
threads.forEach(thread => {
thread.getMessages().forEach(message => {
try {
const dateForName = Utilities.formatDate(
message.getDate(),
Session.getScriptTimeZone(),
'yyyyMMdd_HHmmss'
);
const shortId = message.getId().slice(-6);
const fileName = `${target.name}_${dateForName}_${shortId}.pdf`;
if (fileExists(folder, fileName)) return;
const dateStr = Utilities.formatDate(
message.getDate(),
Session.getScriptTimeZone(),
'yyyy/MM/dd HH:mm:ss'
);
const html = `
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; padding: 24px; font-size: 14px; line-height: 1.6; }
h1 { font-size: 16px; margin: 0 0 8px; }
.meta { color: #666; font-size: 12px; margin-bottom: 16px; }
hr { margin: 16px 0; }
</style>
</head>
<body>
<h1>${escapeHtml(message.getSubject())}</h1>
<div class="meta">受信日時: ${dateStr}</div>
<hr>
${message.getBody()}
</body>
</html>
`;
const blob = Utilities.newBlob(html, 'text/html')
.getAs('application/pdf')
.setName(fileName);
folder.createFile(blob);
} catch (e) {
Logger.log(`保存失敗: ${target.name} / ${message.getId()} / ${e}`);
}
});
});
});
}
// ============================================================
// Gmail検索:全件取得
// ============================================================
function getAllThreads(query) {
const allThreads = [];
let start = 0;
const batchSize = 100;
while (true) {
const threads = GmailApp.search(query, start, batchSize);
if (threads.length === 0) break;
allThreads.push(...threads);
start += batchSize;
}
return allThreads;
}
// ============================================================
// Gmail検索:直近N日分
// ============================================================
function getRecentThreads(query, days) {
return getAllThreads(`${query} newer_than:${days}d`);
}
// ============================================================
// ユーティリティ
// ============================================================
function getOrCreateFolder(path) {
const parts = path.split('/');
let current = DriveApp.getRootFolder();
parts.forEach(part => {
const found = current.getFoldersByName(part);
current = found.hasNext() ? found.next() : current.createFolder(part);
});
return current;
}
function fileExists(folder, fileName) {
return folder.getFilesByName(fileName).hasNext();
}
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ============================================================
// freee OAuth2認証
// ============================================================
function getFreeeService() {
return OAuth2.createService('freee')
.setAuthorizationBaseUrl('https://accounts.secure.freee.co.jp/public_api/authorize')
.setTokenUrl('https://accounts.secure.freee.co.jp/public_api/token')
.setClientId(FREEE_CLIENT_ID)
.setClientSecret(FREEE_CLIENT_SECRET)
.setCallbackFunction('authCallback')
.setPropertyStore(PropertiesService.getUserProperties())
.setScope('read write');
}
function authCallback(request) {
const service = getFreeeService();
const authorized = service.handleCallback(request);
return HtmlService.createHtmlOutput(
authorized ? '認証完了。このタブを閉じてください。' : '認証失敗。'
);
}
function authorize() {
const service = getFreeeService();
if (!service.hasAccess()) {
const url = service.getAuthorizationUrl();
Logger.log('以下のURLをブラウザで開いてください:\n' + url);
} else {
Logger.log('すでに認証済みです');
}
}
function resetAuth() {
getFreeeService().reset();
Logger.log('認証リセット完了');
}
function getCompanyId() {
const service = getFreeeService();
const response = UrlFetchApp.fetch('https://api.freee.co.jp/api/1/companies', {
headers: {
Authorization: 'Bearer ' + service.getAccessToken()
}
});
const data = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(data));
}
// ============================================================
// freeeファイルボックスにアップロード
// ============================================================
function uploadDrivePdfsToFreee() {
const service = getFreeeService();
if (!service.hasAccess()) {
throw new Error('freeeの認証が必要です。authorize() を先に実行してください。');
}
const props = PropertiesService.getScriptProperties();
TARGETS.forEach(target => {
const folder = getOrCreateFolder(target.folder);
const files = folder.getFilesByType('application/pdf');
while (files.hasNext()) {
const file = files.next();
const fileName = file.getName();
if (props.getProperty(file.getId())) {
Logger.log(`スキップ: ${fileName}`);
continue;
}
try {
const response = UrlFetchApp.fetch(
'https://api.freee.co.jp/api/1/receipts',
{
method: 'post',
headers: {
Authorization: 'Bearer ' + service.getAccessToken()
},
payload: {
company_id: FREEE_COMPANY_ID,
receipt: file.getBlob()
},
muteHttpExceptions: true
}
);
const status = response.getResponseCode();
const body = response.getContentText();
if (status < 200 || status >= 300) {
Logger.log(`アップロード失敗: ${fileName} / status=${status} / body=${body}`);
continue;
}
const result = JSON.parse(body);
if (result.receipt) {
props.setProperty(file.getId(), 'uploaded');
Logger.log(`アップロード成功: ${fileName}`);
} else {
Logger.log(`アップロード失敗: ${fileName} / ${body}`);
}
} catch (e) {
Logger.log(`エラー: ${fileName} / ${e}`);
}
}
});
}
// ============================================================
// 初回設定:実行後は削除してください
// ============================================================
function setFreeeCredentials() {
const props = PropertiesService.getScriptProperties();
props.setProperty('FREEE_CLIENT_ID', 'ここにクライアントIDを入れる');
props.setProperty('FREEE_CLIENT_SECRET', 'ここにシークレットを入れる');
Logger.log('設定完了');
}実際に使ってみて
実際に使ってみると、思ったより中途半端でした。
スタバは店内飲食が10%、テイクアウトが軽減税率(8%)です。
注文のたびに税率が変わります。
PDFをそのままアップロードするだけでは、私の環境ではすべて軽減税率として処理されていました。
税区分が正しくないと消費税の申告に影響するので、結局は手動で確認・修正が必要になります。
さらに、freeeは値上げやプランごとの機能制限が続いています。
freeeの機能に依存した運用は、将来的にリスクになりえます。
私がおすすめする方法は、PDFをアップロードするより、メール本文から日付・金額・税区分を抽出してCSVに整形し、freeeに取り込む方法です。
ExcelベースのCSVはfreeeだけでなくマネーフォワードなど他の会計ソフトでも使えます。
会計ソフトを変えても対応できるので、汎用性が高いです。
今回の記事で作った仕組みは、証憑を残すという意味では使えます。
ただ、経理入力まで効率化したいなら、CSV取込のほうがおすすめです。
つまり、このやり方は証憑保存には向いていますが、入力効率化という点ではおすすめではありません。
【余白ログ】
昨日は、税理士業とブログ後に5キロランニング