Blog/開発ブログ

メール転送するだけでfreeeに自動仕訳されるAIを作った話

SES + Lambda + Claude Vision + Supabase + freee API — 全部つなげた

AWS LambdaClaude VisionSupabasefreee APISESPython

はじめに — 課題とモチベーション

毎月届く請求書PDF、まだ手入力してますか?

PDFを開く → 金額を確認 → freeeに手入力 → 税区分を選ぶ → 保存。 これを件数分繰り返す。個人事業主なら月10〜20件、経理担当者なら100件以上。 この作業に意味はなく、しかもミスが起きやすい。

「転送1本でfreeeに自動仕訳されればいい」というシンプルな発想から tanomeru を作り始めた。 SES → Lambda → Claude Vision → Supabase → freee APIをフルに使い、 smoketestが全ステップ通過した段階で、実際に動くシステムになった。 この記事でアーキテクチャと実装の要点を書く。

アーキテクチャ全体像

全体の流れはシンプルだ:

メール受信 (SES)

↓ Receipt Rule → S3 格納

Lambda 起動 (S3 ObjectCreated trigger)

↓ PDF 添付ファイル抽出

Claude Vision (Anthropic API)

↓ 金額・日付・品目・取引先名を OCR → 構造化 JSON

Supabase Bronze 層 → Silver 層

↓ 正規化データ保存

freee API

↓ 仕訳エントリ POST (Plan B: CSV 手動インポート)

完了 — freee上で確認可能

選定理由: SESはメール受信インフラとして信頼性が高くコスト効率がいい。 Claude Visionは日本語PDFのOCR精度が高く、構造化JSONの出力が安定している。 SupabaseはPostgreSQL互換でRow Level Securityが使え、 Bronze/Silverの2層管理をシンプルに書ける。

SES + S3 + Lambda でメール受信

SESのReceipt Ruleをドメインベースで設定し、 着信メールをS3にそのまま保存する。 S3 ObjectCreatedイベントでLambdaをトリガーする構成。

ユーザーは in@tanomeru.ai に請求書メールを転送するだけでいい。 Lambda関数はメールのMIMEパートを解析し、 PDF添付ファイルをbase64デコードしてBytesIOに展開する。 マルチパートのメールでもシングルパートのrawメールでも両方ハンドルできるようにした。

Lambda: PDF 抽出の核心部

import email
import boto3

def extract_pdf(s3_key: str) -> bytes | None:
    s3 = boto3.client('s3')
    raw = s3.get_object(Bucket=BUCKET, Key=s3_key)
    msg = email.message_from_bytes(raw['Body'].read())
    for part in msg.walk():
        if part.get_content_type() == 'application/pdf':
            return part.get_payload(decode=True)
    return None

冪等性の担保も重要。Lambda起動直後にSupabase Bronze層へINSERTし、 S3キーをユニークキーとして重複処理を防いでいる。 SQS Dead Letter Queueも設定して、失敗したイベントは再処理できる。

Claude Vision で PDF OCR — 構造化 JSON を引き出す

PDFをClaudeに渡すにはbase64エンコードしてdocument タイプで送る。 プロンプトに「金額・日付・取引先名・品目をJSONで返してほしい」と指示するだけで 安定してパースできる。

Claude Vision 呼び出し例

import anthropic, base64, json

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "document",
                "source": {
                    "type": "base64",
                    "media_type": "application/pdf",
                    "data": base64.b64encode(pdf_bytes).decode()
                }
            },
            {
                "type": "text",
                "text": (
                    "この請求書から以下をJSONで抽出してください:\n"
                    "amount(税抜金額/int), tax_amount(int), "
                    "date(YYYY-MM-DD), vendor_name(str), "
                    "items([{name: str, amount: int}])"
                )
            }
        ]
    }]
)

data = json.loads(response.content[0].text)

日本語請求書の精度は高い。金額のカンマ区切り、和暦対応、 「消費税」「税抜」などの表記ゆれも安定してパースできる。 ただし手書きの書類やスキャン品質が低いPDFは精度が落ちる。 その場合はprocessing_status = 'needs_review'にして人間に確認を促すフォールバックを設けた。

Supabase で Bronze / Silver 2 層管理

データ管理はメダリオンアーキテクチャ的な2層構成を採用した:

  • Bronze 層 (invoices_raw): S3キー・生メールヘッダー・処理ステータスを保存。 Lambda起動直後にINSERTして処理の冪等性を担保。source_email raw_email_s3_key が非NULLなら受信成功の証明になる。
  • Silver 層 (invoices_structured): Claude Visionが返した構造化JSONを正規化して保存。 金額・日付・取引先名・品目が揃ったらprocessing_status 'completed'にUPDATE。

Row Level Securityを有効にして、ユーザーIDでフィルタリング。 複数ユーザーが同じDBを使っても互いのデータが見えない構成。 Supabase CLIでsupabase db pushするだけで本番DBにマイグレーションが当たるので開発体験もいい。

freee API で仕訳登録

Silver層のデータをfreee APIの POST /api/1/deals で登録する。 freeeのOAuthフローでアクセストークンを取得し、Supabaseにトークンを暗号化保存。 Lambdaは毎回Supabaseからトークンを取り出して、有効期限切れなら自動でリフレッシュする。

勘定科目コードはClaudeが品目から推定するが、不明な場合は仮払金で登録してユーザーに確認を促すフォールバック戦略をとっている。 完全自動化よりも「間違いを減らしつつ最終確認は人間」のラインを守った。

Plan B: CSV 手動インポートも実装済み。 freee OAuth接続前でもSilver層のデータをCSVエクスポートして freeeに手動インポートできる。 設定が完了する前でも価値を届けられる構成は、βテスト運用で重要だった。

smoketest で全ステップ通過確認 — 「動く」を証明した

実際にメールを送信してパイプライン全体を通す smoketest_e2e.py を書いた。受入条件は4つ:

  1. smoketest_e2e.py が実パイプラインで完走 (exit 0)
  2. invoices_raw に Bronze レコード 1 件以上 (source_email / raw_email_s3_key 非 NULL)
  3. invoices_structured に Silver レコード 1 件以上 (processing_status = 'completed')
  4. check_gonogo.py で「β パイプライン疎通 ✅」が出力される

全条件をクリアした段階でβ公開を決断した。 テスト中にCloudWatchアラームが一時発火したが、 原因はsmoketestのテストユーザーに freee_company_id が未設定だったこと。Pydanticのバリデーションエラーがアラームのトリガーになっていた。Optional化して解消済み。本番の継続障害はゼロ。

βテスターを3名だけ募集中

freeeを使っていて、請求書の手入力を減らしたい個人事業主・フリーランスの方に使ってもらいたい。

  • 完全無料 (β期間中)
  • 30分の Slack フィードバック MTG あり
  • freee OAuth 接続後すぐ試せます
  • OAuth 接続前でも CSV エクスポートで価値を確認できます

Zenn / Qiita 読者の方はコメント欄に「試したい」と書いてください。 または下のリンクから waitlist に登録してもらえると助かります。

canonical URL: https://lp.tanomeru.ai/blog/how-we-built-tanomeru(Zenn / Qiita クロスポスト時はこの URL を canonical に指定してください)