freee会計への経費登録を半自動化した——明細PDF抽出の設計と、freee API自前アプリのハマりどころ

個人事業主の月次ルーチンに「経費の登録」がある。銀行明細を眺めて、税理士報酬や家賃を見つけて、freeeに手で入力する。件数は月に10件足らずだが、毎月確実に発生し、たまに漏れる。今回これをClaude Codeと組んで半自動化した。設計判断と、実装中に踏んだ罠——特に後半のfreee API自前アプリ作成のハマりどころ——を記録しておく。

「口座連携すればいい」が通らない理由

freeeを使っているなら「口座を連携して自動で経理を使えば済む話では」と思うだろう。実際、事業専用口座はそれが正解で、自分も事業用のネット銀行は連携している。

問題は生活口座だ。個人事業主は生活口座から事業経費が引き落とされることが普通にある(税理士報酬、自宅兼事務所の家賃、携帯代など)。この口座を連携すると:

  • スーパーやコンビニを含む全明細がfreeeに流れ込む。事業に関係する行は月に数行、残り9割以上はプライベートだ
  • プライベート明細を毎月「無視」処理し続ける作業が発生する。9割を捨てるルールを整備し続けるのは本末転倒だ
  • 税理士とfreeeを共有していれば、個人のカード利用や資産移動まで見えることになる

さらにfreee APIには構造的な制約もある。連携済み口座の明細(wallet_txn)とAPIで作る取引(deal)は別データで、APIでdealを作っても明細は消し込まれない(freee-api-schema issue #541)。つまり連携済み口座に対してAPIで登録すると二重計上への道が開く。

そこで逆のアプローチを取った。

生活口座は連携しない。明細PDFから事業の明細だけをホワイトリストで抽出してAPIで登録する。プライベートは最初からfreeeに存在させない。

freee連携が「全部取り込んでから分類」なのに対し、こちらは「事業のものだけ選んで持っていく」。未連携口座なら消し込み問題も起きない。

作ったもの

Pythonの小さなCLIだ。入力は銀行(SMBCダイレクト)とクレジットカード(三井住友カード)の明細PDF。

# 既存取引と突合して登録プランを作成(dry-run)
uv run python -m meisai plan data/bank.pdf data/card.pdf --deals data/deals_cache.json --out data/plan.json

抽出ルールは「店名・摘要のパターン+金額条件」のホワイトリストで、今は5つ。税理士報酬(銀行)、家賃(銀行)、Google Workspace、AWS、携帯の事業回線(カード)。ここにない明細には一切触らない。技術書やタクシーのような「人間しか判断できない」経費は、従来通り手動登録に残した。全部自動化しようとすると要判断キューだの何だのと設計が膨らむ。迷わない明細だけ扱うと割り切ったことで、ツールは劇的に単純になった。

突合(重複チェック)は「金額一致かつ日付±5日」。±5日なのは、カードの利用日と手動登録の日付が数日ズレる実績があったからだ。

月次運用はClaude Codeに任せる。月末に明細PDFをダウンロードしてリポジトリのdata/に置き、「明細を登録して」と言うと、Claude Codeがツールを回して「今月の登録対象はこの3件」と表を出す。GOと言えばfreee APIで登録して取引IDを報告してくる。人間の作業はPDFのダウンロードとGO判断だけになった。

PDF解析で踏んだ罠

金額欄が信用できない——残高差分方式

SMBCの明細PDFをpypdfでテキスト抽出すると、レイアウトが崩れて取引行と金額欄が分離する。しかも行によっては摘要が金額欄側に漏れ、取引行から摘要が消える。金額欄と取引行を位置ベースで対応付けるのは脆すぎる。

そこで金額欄を捨てた。明細は新しい順に並んでいて各行に残高が付いているから、隣り合う行の残高の差分を取れば金額と入出金の向きが正確に復元できる。レイアウトがどう崩れても残高列さえ拾えれば成立する。摘要が消えた行も「摘要が空で金額105,000円なら家賃」というフォールバックで救える。

康熙部首の罠

カード明細で「ドコモご利用料金」の行だけがどうしてもマッチしなかった。原因を調べて拍子抜けした。PDF内の文字が「ドコモご利⽤料⾦」——「⽤」と「⾦」が漢字ではなく康熙部首(U+2F64、U+2FA6) だったのだ。見た目は完全に同じで、コードポイントだけが違う。

対策はNFKC正規化。康熙部首は通常漢字に、全角英数は半角に寄る。カード明細の店名照合は全部この正規化を通すことにした。PDF由来のテキストを扱うなら最初からNFKCを通すべし、という教訓だ。

unicodedata.normalize("NFKC", "ドコモご利⽤料⾦")  # → "ドコモご利用料金"

金額閾値は事情で壊れる

携帯は同じ日に事業回線と私用回線の2行が並ぶ。当初「5,000円以上が事業回線」という閾値で区別していたが、翌月からプラン変更で事業回線が3,000円前後になることが分かった。私用回線は約2,000円。閾値を下げるだけでは際どすぎる。

結局「同月のマッチ行のうち最大額の1件だけ採用」という相対比較に変えた。金額の絶対値に依存しないので、今後料金が変わってもルールの修正は不要になる。

手作業は信用できない——二重登録の謎解き

ツール実装前、同じ作業を手でやっていた。既存取引をAPIで全件取得してjqで「金額105,000円の取引」を検索し、家賃が4月分しか登録されていないことを確認して、1〜3月分と5〜7月分を登録した。

ところがツールのE2E検証で妙なことが起きた。抽出した1〜3月の家賃が、自分が登録した取引ではなく別の既存取引にマッチする。調べると、1〜3月の家賃は元から登録済みだった。つまり手作業の登録は三重に間違っていた——いや、二重に登録していた。

なぜjq検索が見落としたのか。原因は、複数の保存ファイルをglobで一括処理した際に、構造の違うファイル(勘定科目一覧)が混ざってjqがエラーで途中終了し、後半のファイルが未処理になっていたからだ。エラーメッセージは出ていたが、結果も一部出ていたので「検索は完了した」と思い込んだ。

重複3件はAPIで削除して事なきを得たが、皮肉なのはその後だ。同じ登録前データをツールに食わせたら、ツールは1〜3月の家賃を正しく「登録済み」と判定した。突合ロジックは機械的に全件を舐めるから、途中でエラーに食われたりしない。「重複チェックはツールの最重要機能」と設計段階で書いていたが、その正しさを自分の失敗が実証する形になった。

freee API自前アプリの作り方とハマりどころ

ここからが本題の後半だ。取引の登録まではfreee公式のMCPサーバー(Remote MCP)で完結していたが、証憑(領収書)のファイルアップロードだけはRemote MCPでは不可能だった。POST /api/1/receiptsはmultipart/form-dataが必要で、これに対応するのはローカルモード専用機能なのだ。領収書のためだけにfreeeのWeb UIへログインするのは面倒すぎる。

解決策は自前のfreeeアプリを作ってOAuthをツールに持たせること。無料でできるが、ハマりどころが多かったので手順込みで記録する。

手順

  1. freeeアプリストアの開発者ページで開発者登録(無料)
  2. アプリ管理から「新規追加」でアプリ作成。タイプはプライベートアプリを選ぶ(自社限定・アプリストア公開不可・5事業所まで。個人利用ならこれで十分、ずっと無料)
  3. 権限設定タブで必要なデータ種別だけ有効化(今回は[会計]取引と[会計]ファイルボックスの参照・更新)して保存
  4. コールバックURLはurn:ietf:wg:oauth:2.0:oob(CLIツールなのでリダイレクト先がない。oobなら認可コードが画面に表示される)
  5. 基本情報タブのClient ID / Client Secretを控える

認可フローは教科書通りの認可コードフローだ。認可URLをブラウザで開き、事業所を選んで許可すると認可コードが表示されるので、それをトークンに交換する。

ハマりどころ1: その番号、Client IDではない

アプリを作るとURLやアプリ一覧に8桁くらいの番号が出てくる。これはただの管理用アプリIDで、Client IDではない。実際に最初これをClient IDだと思い込んだ。本物のClient IDとClient Secretはアプリ詳細の基本情報タブにある長い文字列の方だ。「それらしい番号」が先に目に入る画面構成なので、素直に引っかかった。

ハマりどころ2: refresh_tokenは使うたびに変わる

freeeのaccess_tokenは6時間で失効する。refresh_tokenで更新すればよいのだが、freeeはrefresh_tokenがローテートされる仕様だ。つまりリフレッシュするたびに新しいrefresh_tokenが返ってきて、古いものは無効になる

これが何を意味するか。リフレッシュ後に新しいrefresh_tokenを保存し損ねると、次回から認証が完全に詰む(再認可が必要になる)。「トークン更新のレスポンスを読んでaccess_tokenだけ使い、保存を忘れる」というのは本当にやりがちな実装ミスだ。トークン管理はこうなる:

def get_access_token() -> str:
    tokens = json.loads(TOKEN_PATH.read_text())
    if time.time() < tokens["expires_at"] - 300:
        return tokens["access_token"]
    r = requests.post(TOKEN_URL, data={
        "grant_type": "refresh_token",
        "client_id": cid, "client_secret": secret,
        "refresh_token": tokens["refresh_token"],
    })
    data = r.json()
    _save_tokens(data)  # ← ローテートされたrefresh_tokenの保存が生命線
    return data["access_token"]

ハマりどころ3: multipartにContent-Typeヘッダを固定しない

JSON用のAPIクライアントにContent-Type: application/jsonをヘッダとして固定で持たせていると、multipartのアップロードで死ぬ。requestsはfiles=を渡せばboundary付きのContent-Typeを自動設定するので、ヘッダにはAuthorizationだけ置いて、Content-Typeはリクエストごとにライブラリに任せるのが正しい。

おまけ: 削除はソフトデリート

E2Eテストでアップロードした証憑をDELETEしたら204が返ったのに、直後のGETが200を返して一瞬焦った。レスポンスを見るとstatus: "deleted"。freeeの証憑削除は論理削除で、ゴミ箱に残る仕様だった。テストのクリーンアップを「GETが404になること」で検証すると永遠に失敗する。

まとめ

  • 生活口座はfreee連携せず、明細PDFからのホワイトリスト抽出が快適だ。プライベートがfreeeに存在しないことは、消し込み作業の削減以上に精神衛生に効く
  • PDFテキストは信用しない。位置より差分(残高)、文字はNFKC正規化
  • 重複チェックを人力でやってはいけない。自分は間違え、ツールは間違えなかった
  • freee自前アプリは無料で作れるが、「アプリID≠Client ID」「refresh_tokenローテート」の2つは知らないと確実にハマる

月末の運用は「PDFをぶん投げてGOと言う」だけになった。リポジトリは整理して追って公開する予定だ。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール