GASでGoogle DriveのPDFをfreeeファイルボックスに自動アップロードする方法

前回の記事で、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キロランニング

目次