メール転送するだけでfreeeに自動仕訳されるAIを作った話
SES + Lambda + Claude Vision + Supabase + freee API — 全部つなげた
はじめに — 課題とモチベーション
毎月届く請求書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つ:
smoketest_e2e.pyが実パイプラインで完走 (exit 0)invoices_rawに Bronze レコード 1 件以上 (source_email / raw_email_s3_key 非 NULL)invoices_structuredに Silver レコード 1 件以上 (processing_status = 'completed')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 に指定してください)