freee APIで経理業務を自動化した話 -- GTMエンジニアリングの「社内適用」実践
GTMエンジニアリングの手法は営業だけでなく社内業務にも適用できる。freee APIを使った経費チェック自動化、銀行明細突合、証憑管理の自動化をTypeScriptで実装した実践例を共有する。
freee APIで経理業務を自動化した話 -- GTMエンジニアリングの「社内適用」実践
GTMエンジニアリングは営業だけのものではない
ここまでのシリーズでは、GTMエンジニアリングの4層モデル(技術スタック全体像)、Claude APIを使った営業パイプライン自動化(AI活用実践)、CLAUDE.mdによる暗黙知のコード化(セールスエンジニアリング)を扱ってきた。
4層モデルをおさらいする。
┌─────────────────────────────────────────────┐
│ Layer 4: 出力層(Slack / CRM / Issue) │
├─────────────────────────────────────────────┤
│ Layer 3: オーケストレーション層 │
│ GitHub Actions / n8n │
├─────────────────────────────────────────────┤
│ Layer 2: AI処理層(Claude API) │
├─────────────────────────────────────────────┤
│ Layer 1: データ収集層(API統合) │
└─────────────────────────────────────────────┘
営業自動化ではLayer 1がClay/Apollo、Layer 4がCRM/Smartleadだった。しかしこのアーキテクチャは、データソースと出力先を差し替えるだけで社内業務にそのまま転用できる。
今回は経理業務にこの4層モデルを適用した。Layer 1をfreee API、Layer 4をSlack通知+GitHub Issueに差し替え、経費チェック・銀行明細突合・証憑管理を自動化した実践例を共有する。
社内業務に適用する意味
「GTMエンジニアの仕事は営業支援だけでは?」という疑問があるかもしれない。
たしかに名前の通り、GTM(Go-to-Market)は市場投入に関わるポジションだ。しかしGTMエンジニアが日常的に鍛えているスキルセットを分解すると、営業固有のものはほとんどない。
- API統合: 外部SaaSのAPIを叩いてデータを取得し、正規化する
- ワークフロー自動化: 定期実行のバッチ処理を設計し、異常検知でアラートを出す
- AI処理: 構造化されていないデータをLLMで分析し、意思決定可能な情報に変換する
- 通知設計: 適切なチャネルに適切な粒度で結果を届ける
これらは経理でも人事でもカスタマーサクセスでも、どの業務領域でも使える汎用スキルだ。
GTMエンジニアが社内業務を自動化すると、2つの効果がある。1つは自社の業務効率化による直接的なコスト削減。もう1つは、自社で試した自動化パターンを顧客向けソリューションに転用できること。自分が使って効果を実感した仕組みは、顧客に提案するときの説得力が全く違う。
freee会計APIの概要
freee会計APIはREST形式で、会計データへのCRUDアクセスを提供している。今回の自動化に関係する主要なエンドポイントを整理する。
| エンドポイント | 用途 | 今回の用途 |
|---|---|---|
GET /api/1/deals | 取引(収入・支出)一覧取得 | 経費チェック、重複検出、異常値分析 |
GET /api/1/wallet_txns | 口座明細一覧取得 | 銀行明細突合 |
GET /api/1/expense_applications | 経費申請一覧取得 | 未承認経費の検出 |
GET /api/1/account_items | 勘定科目一覧取得 | カテゴリ別分析の軸 |
POST /api/1/receipts | 証憑アップロード | 領収書の自動登録 |
POST /api/1/deals | 取引作成 | Amazon購入の自動仕訳 |
OAuth認証フロー
freee APIはOAuth 2.0の認可コードフローを採用している。個人利用のバッチ処理では、一度ブラウザで認可コードを取得した後はリフレッシュトークンで自動更新する運用になる。
typescript// src/api/auth.ts import axios from 'axios'; import type { TokenResponse } from '../types.js'; export async function refreshAccessToken( clientId: string, clientSecret: string, refreshToken: string ): Promise<TokenResponse> { const response = await axios.post<TokenResponse>( 'https://accounts.secure.freee.co.jp/public_api/token', { grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret, refresh_token: refreshToken, } ); return response.data; }
ポイントはリフレッシュトークンのローテーションだ。freeeのOAuthではトークン更新のたびに新しいリフレッシュトークンが発行される。古いトークンは無効化されるため、更新後のトークンを必ず保存しなければならない。GitHub Actionsで実行する場合はGITHUB_OUTPUT経由で新しいトークンを次回実行に引き継ぐ仕組みが必要になる。
データモデル
freeeの取引データの構造を理解しておく。1つの取引(Deal)は複数の明細(Detail)を持ち、各明細に勘定科目と金額が紐づく。
typescript// src/types.ts export interface FreeeTransaction { id: number; company_id: number; issue_date: string; // 発生日 due_date: string | null; // 支払期日 amount: number; // 合計金額 partner_id: number | null; partner_name: string | null; status: 'settled' | 'unsettled'; type: 'income' | 'expense'; ref_number: string | null; details: FreeeTransactionDetail[]; } export interface FreeeTransactionDetail { id: number; account_item_id: number; account_item_name: string; // 勘定科目名(「消耗品費」「通信費」等) tax_code: number; amount: number; description: string; receipt_ids?: number[]; // 紐づいた証憑ID }
このreceipt_idsの有無で証憑添付チェックができる。またpartner_nameとamountの組み合わせで重複検出ができる。APIのレスポンス構造がそのままチェックロジックの設計に直結するので、最初にこの型定義をしっかり書いておくと後が楽になる。
APIクライアントの設計
freee APIとのやり取りを一箇所にまとめるクライアントクラスを実装した。
typescript// src/api/client.ts import axios, { type AxiosInstance, type AxiosError } from 'axios'; import { refreshAccessToken } from './auth.js'; import type { FreeeDealsResponse, FreeeWalletTransactionsResponse, FreeeAccountItemsResponse, } from '../types.js'; const BASE_URL = 'https://api.freee.co.jp'; export class FreeeApiClient { private client: AxiosInstance; private accessToken: string = ''; private refreshToken: string; private readonly clientId: string; private readonly clientSecret: string; private readonly companyId: number; constructor(config: { clientId: string; clientSecret: string; refreshToken: string; companyId: number; }) { this.clientId = config.clientId; this.clientSecret = config.clientSecret; this.refreshToken = config.refreshToken; this.companyId = config.companyId; this.client = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json' }, }); // 401時にトークンを自動リフレッシュ this.client.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config; if (error.response?.status === 401 && originalRequest) { await this.refreshAccessTokenInternal(); originalRequest.headers['Authorization'] = `Bearer ${this.accessToken}`; return this.client.request(originalRequest); } throw error; } ); } async initialize(): Promise<void> { await this.refreshAccessTokenInternal(); } private async refreshAccessTokenInternal(): Promise<void> { const tokenResponse = await refreshAccessToken( this.clientId, this.clientSecret, this.refreshToken ); this.accessToken = tokenResponse.access_token; this.refreshToken = tokenResponse.refresh_token; this.client.defaults.headers.common['Authorization'] = `Bearer ${this.accessToken}`; } }
特筆すべきはAxiosのレスポンスインターセプターで401をハンドリングしている点だ。freeeのアクセストークンの有効期限は24時間だが、GitHub Actionsの実行タイミングによっては期限切れになっていることがある。インターセプターでリトライすることで、呼び出し側は認証のことを一切気にせずAPIを叩ける。
営業自動化でHubSpot APIクライアントを書いたとき(AI活用実践の共通基盤部分)と全く同じパターンだ。API統合の設計はドメインが変わっても変わらない。
ユースケース1: 経費の無駄・異常検出
高額経費アラート
最もシンプルなチェック。設定した閾値を超える支出を検出する。
typescript// src/checkers/high-amount.ts import type { CheckConfig, CheckResult, FreeeTransaction } from '../types.js'; export function checkHighAmount( transactions: FreeeTransaction[], config: CheckConfig ): CheckResult[] { const results: CheckResult[] = []; const { warning, critical } = config.thresholds.high_amount; const exemptCategories = config.categories.high_amount_exempt; for (const tx of transactions) { if (tx.type !== 'expense') continue; const amount = Math.abs(tx.amount); // 家賃などの除外カテゴリはスキップ const hasExemptCategory = tx.details.some((detail) => exemptCategories.some((exempt) => detail.account_item_name.includes(exempt) ) ); if (hasExemptCategory) continue; if (amount >= critical) { results.push({ type: 'critical', category: 'high_amount', title: `高額経費: ¥${amount.toLocaleString()}`, description: `取引先: ${tx.partner_name || '不明'} / 日付: ${tx.issue_date}`, transactions: [tx], }); } else if (amount >= warning) { results.push({ type: 'warning', category: 'high_amount', title: `高額経費(警告): ¥${amount.toLocaleString()}`, description: `取引先: ${tx.partner_name || '不明'} / 日付: ${tx.issue_date}`, transactions: [tx], }); } } return results; }
閾値はYAMLの設定ファイルで管理している。個人事業主ならwarning: 50000、critical: 100000あたりが妥当だが、法人なら桁が変わる。重要なのはコードに閾値をハードコードしない設計だ。
重複経費の検出
同一金額・同一取引先の支出が設定日数以内に複数回発生している場合に警告する。二重支払いや登録ミスの検出に効く。
typescript// src/checkers/duplicate.ts export function checkDuplicates( transactions: FreeeTransaction[], config: CheckConfig ): CheckResult[] { const results: CheckResult[] = []; const { days, amount_tolerance } = config.thresholds.duplicate; const expenses = transactions.filter((tx) => tx.type === 'expense'); // 金額・取引先でグループ化 const groups = new Map<string, FreeeTransaction[]>(); for (const tx of expenses) { const amount = Math.abs(tx.amount); const partner = tx.partner_name || 'unknown'; const key = `${amount}_${partner}`; if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(tx); } for (const [key, txs] of groups) { if (txs.length < 2) continue; txs.sort((a, b) => new Date(a.issue_date).getTime() - new Date(b.issue_date).getTime() ); for (let i = 0; i < txs.length - 1; i++) { for (let j = i + 1; j < txs.length; j++) { const daysDiff = Math.abs( (new Date(txs[j]!.issue_date).getTime() - new Date(txs[i]!.issue_date).getTime()) / (1000 * 60 * 60 * 24) ); if (daysDiff <= days) { results.push({ type: 'warning', category: 'duplicate', title: `重複の可能性: ¥${Math.abs(txs[i]!.amount).toLocaleString()}`, description: [ `取引先: ${txs[i]!.partner_name || '不明'}`, `日付: ${txs[i]!.issue_date} / ${txs[j]!.issue_date}`, `間隔: ${Math.round(daysDiff)}日`, ].join(' / '), transactions: [txs[i]!, txs[j]!], }); } } } } return results; }
amount_toleranceパラメータを入れてあるのがポイントだ。為替レートの影響で海外SaaSの請求額が毎月微妙に変わることがある。許容誤差をゼロにすると正当な取引まで重複扱いしてしまうので、数十円程度の幅を持たせている。
カテゴリ別の異常値検出
過去6ヶ月の実績と今月を比較し、標準偏差のN倍を超えたカテゴリを警告する。「先月まで月3万円だった通信費が今月だけ15万円」といった状況を機械的に拾う。
typescript// src/checkers/category-anomaly.ts export function checkCategoryAnomalies( currentTransactions: FreeeTransaction[], historicalTransactions: FreeeTransaction[], config: CheckConfig ): CheckResult[] { const results: CheckResult[] = []; const { std_deviation } = config.thresholds.category_anomaly; // カテゴリ別・月別に金額を集計(過去データ) const categoryMonthlyAmounts = new Map<string, Map<string, number>>(); for (const tx of historicalTransactions) { if (tx.type !== 'expense') continue; for (const detail of tx.details) { const category = detail.account_item_name; const yearMonth = tx.issue_date.slice(0, 7); // "YYYY-MM" // ... 月別金額を蓄積 } } // 今月の金額と過去平均を比較 for (const [category, currentAmount] of currentCategoryAmounts) { const historicalAmounts = Array.from(monthlyMap.values()); const mean = calculateMean(historicalAmounts); const stdDev = calculateStdDev(historicalAmounts, mean); if (isOutlier(currentAmount, mean, stdDev, std_deviation)) { const percentIncrease = ((currentAmount - mean) / mean) * 100; results.push({ type: 'warning', category: 'category_anomaly', title: `カテゴリ異常値: ${category}`, description: `今月: ¥${currentAmount.toLocaleString()} / 平均: ¥${Math.round(mean).toLocaleString()} / 増加率: +${Math.round(percentIncrease)}%`, }); } } return results; }
営業パイプラインでいう「リードスコアリング」(AI活用実践)と同じ構造だ。過去データから統計量を計算し、今の値が異常かどうかを判定する。ドメインが「リードの質」から「経費の妥当性」に変わっただけで、パターンは同一。
ユースケース2: 銀行明細との突合
未処理明細の自動検出
freeeは銀行口座やクレジットカードの明細を自動取得する機能を持っているが、取得された明細は手動で「どの取引に対応するか」を紐付ける必要がある。この未処理の明細を検出する。
typescript// src/checkers/bank-reconciliation.ts export function checkBankReconciliation( walletTransactions: FreeeWalletTransaction[] ): CheckResult[] { const results: CheckResult[] = []; // 未処理の明細のみ抽出 const unrecognized = walletTransactions.filter( (txn) => txn.status === 'unrecognized' ); if (unrecognized.length === 0) return results; // 口座別にグループ化してサマリーを作成 const byWallet = new Map<string, FreeeWalletTransaction[]>(); for (const txn of unrecognized) { const key = `${txn.walletable_type}_${txn.walletable_id}`; if (!byWallet.has(key)) { byWallet.set(key, []); } byWallet.get(key)!.push(txn); } for (const [walletKey, txns] of byWallet) { const totalAmount = txns.reduce( (sum, txn) => sum + Math.abs(txn.amount), 0 ); results.push({ type: txns.length >= 10 ? 'critical' : 'warning', category: 'bank_reconciliation', title: `未登録明細: ${txns.length}件`, description: [ `口座: ${txns[0]?.walletable_name || walletKey}`, `合計: ¥${totalAmount.toLocaleString()}`, `内訳: 入金${txns.filter((t) => t.amount > 0).length}件 / 出金${txns.filter((t) => t.amount < 0).length}件`, ].join(' / '), walletTransactions: txns, }); } return results; }
freee APIのwallet_txnsエンドポイントはstatusフィールドでrecognized(処理済み)とunrecognized(未処理)を区別してくれる。このフィルタリングだけで「何が残っているか」が一目で分かるようになる。
定期支払いの欠損チェック
毎月発生するはずの支払い(SaaSの月額料金など)が今月まだ登録されていない場合に警告する。支払い忘れや登録漏れの検出に使う。
typescript// src/checkers/recurring-payment.ts export function checkRecurringPayments( currentTransactions: FreeeTransaction[], historicalTransactions: FreeeTransaction[], config: CheckConfig ): CheckResult[] { const results: CheckResult[] = []; const { months, tolerance_days } = config.thresholds.recurring_check; // 過去N月分の取引先+カテゴリ別に月ごとの支払いを集計 const paymentPatterns = new Map< string, Map<string, { amount: number; day: number }> >(); for (const tx of historicalTransactions) { if (tx.type !== 'expense') continue; const partner = tx.partner_name || 'unknown'; const category = tx.details[0]?.account_item_name || 'unknown'; const key = `${partner}__${category}`; // ... 月別に集計 } // 全月で支払いがあるパターンを抽出し、 // 今月の取引に含まれていなければ警告 for (const pattern of recurringPatterns) { if (currentPaymentKeys.has(patternKey)) continue; const expectedPayDay = pattern.typicalDay + tolerance_days; if (currentDay < expectedPayDay) continue; results.push({ type: 'warning', category: 'recurring_payment', title: `定期支払い未検出: ${pattern.partnerName}`, description: [ `カテゴリ: ${pattern.category}`, `通常金額: ¥${pattern.typicalAmount.toLocaleString()}`, `通常支払日: 毎月${pattern.typicalDay}日頃`, ].join(' / '), }); } return results; }
tolerance_daysを入れているのは、クレジットカードの引き落とし日が週末や祝日の関係で数日ずれることがあるためだ。5日程度の余裕を持たせておくと、誤検出が大幅に減る。
ユースケース3: 証憑管理の自動化
領収書未添付の取引検出
インボイス制度の導入以降、証憑管理の重要度は上がっている。取引はあるのに領収書が添付されていない状態を検出する。
typescript// src/checkers/missing-receipt.ts const RECEIPT_NOT_REQUIRED_CATEGORIES = [ '給料賃金', '法定福利費', '減価償却費', '支払利息', '租税公課', '預り金', '振込手数料', ]; const SMALL_AMOUNT_THRESHOLD = 30000; export function checkMissingReceipts( transactions: FreeeTransaction[] ): CheckResult[] { const results: CheckResult[] = []; for (const tx of transactions) { if (tx.type !== 'expense') continue; // 証憑が不要な勘定科目はスキップ const hasExemptCategory = tx.details.some((detail) => RECEIPT_NOT_REQUIRED_CATEGORIES.some((cat) => detail.account_item_name.includes(cat) ) ); if (hasExemptCategory) continue; // 証憑IDの有無をチェック const hasReceipt = tx.details.some( (detail) => detail.receipt_ids && detail.receipt_ids.length > 0 ); if (!hasReceipt) { // 高額は個別に、少額はまとめて報告 if (Math.abs(tx.amount) >= SMALL_AMOUNT_THRESHOLD) { results.push({ type: 'warning', category: 'missing_receipt', title: `証憑未添付: ¥${Math.abs(tx.amount).toLocaleString()}`, description: `取引先: ${tx.partner_name || '不明'} / 科目: ${tx.details[0]?.account_item_name || '不明'}`, transactions: [tx], }); } } } return results; }
RECEIPT_NOT_REQUIRED_CATEGORIESのリストが重要だ。給料賃金や減価償却費は証憑がそもそも存在しない科目なので、チェック対象から外す。また税込3万円未満は請求書等の保存義務が緩和される場合があるため、少額取引はまとめてinfo扱いにしている。
Amazon注文履歴との自動マッチング
個人事業主や小規模企業で最も面倒な経理作業の1つが、Amazonで購入した備品の仕訳だ。クレジットカード明細には「AMAZON.CO.JP」としか出ないため、何を買ったのか領収書を見ないと分からない。これを自動化した。
typescript// src/amazon-to-freee.ts(概略) import { downloadAmazonReceipts } from './amazon/download-receipts.js'; import { FreeeApiClient } from './api/client.js'; import type { FreeeCreateDealRequest } from './types.js'; async function processAmazonOrder( client: FreeeApiClient, receipt: AmazonReceipt, options: { accountItemId: number; taxCode: number; walletableId: number } ): Promise<void> { // 1. 領収書PDFをfreeeにアップロード const receiptResponse = await client.uploadReceipt( receipt.pdfPath, `Amazon注文 ${receipt.orderId}` ); // 2. 取引を作成(仕訳) const dealRequest: FreeeCreateDealRequest = { issue_date: receipt.orderDate, type: 'expense', ref_number: receipt.orderId, details: [{ account_item_id: options.accountItemId, tax_code: options.taxCode, amount: receipt.amount, description: `Amazon: ${receipt.itemName}`, }], payments: [{ date: receipt.orderDate, from_walletable_type: 'credit_card', from_walletable_id: options.walletableId, amount: receipt.amount, }], receipt_ids: [receiptResponse.receipt.id], }; await client.createDeal(dealRequest); }
ポイントはreceipt_idsフィールドだ。freee APIでは取引作成時に証憑IDを渡すことで、取引と領収書を一発で紐付けられる。アップロード→取引作成を2ステップでやると紐付け漏れが起きやすいが、取引作成時に同時に渡すことでそのリスクを排除できる。
証憑アップロードの自動化
手元にある領収書PDFを一括でfreeeにアップロードし、取引先名から適切な勘定科目を自動推定する仕組みも作った。
typescript// src/upload-receipts.ts(概略) // サービス名 → freee勘定科目・取引先マッピング const SERVICE_MAP: Record<string, { partnerName: string; accountCategory: string; description: string; }> = { anthropic: { partnerName: 'Anthropic, PBC', accountCategory: '通信費', description: 'Claude API利用料', }, aws: { partnerName: 'Amazon Web Services', accountCategory: '通信費', description: 'AWS利用料', }, github: { partnerName: 'GitHub, Inc.', accountCategory: '通信費', description: 'GitHub利用料', }, // ... 30以上のサービスをマッピング };
このマッピングテーブルは、CLAUDE.mdで営業チームの暗黙知をコード化した(セールスエンジニアリング)のと同じ発想だ。経理担当者が「Anthropicの請求は通信費で処理する」という判断を毎回頭の中でやっている。その判断基準をコードに落とし込む。
ワークフロー全体のアーキテクチャ
7つのチェッカーをオーケストレーションする全体の構成を示す。
[GitHub Actions: 毎朝9時(JST)実行]
│
├── freee API認証(リフレッシュトークン自動更新)
│
├── データ取得(並列実行)
│ ├── 今月の取引一覧
│ ├── 過去6ヶ月の取引一覧(異常値分析用)
│ ├── 未承認の経費申請
│ └── 未処理の口座明細
│
├── 7つのチェック実行
│ ├── 高額経費アラート
│ ├── 重複経費検出
│ ├── 未承認経費チェック
│ ├── 銀行明細突合
│ ├── カテゴリ別異常値
│ ├── 定期支払い欠損
│ └── 証憑未添付チェック
│
├── サマリー集計(Critical / Warning / Info)
│
└── 出力
├── Slack通知(チェック結果サマリー)
└── GitHub Actions Summary(詳細ログ)
メイン処理のコードを示す。
typescript// src/main.ts async function main() { const config = loadConfig(); const envConfig = getEnvConfig(); const client = new FreeeApiClient({ clientId: envConfig.clientId, clientSecret: envConfig.clientSecret, refreshToken: envConfig.refreshToken, companyId: envConfig.companyId, }); await client.initialize(); // データ取得を並列実行 const [ dealsResponse, historicalDealsResponse, expenseAppsResponse, walletTxnsResponse, ] = await Promise.all([ client.getAllDeals({ start_date: formatDate(getMonthStart(now)), end_date: formatDate(getMonthEnd(now)), type: 'expense', }), client.getAllDeals({ start_date: formatDate(getMonthsAgoStart(6, now)), end_date: formatDate(getMonthEnd(now)), type: 'expense', }), client.getExpenseApplications({ status: 'in_progress' }), client.getUnrecognizedWalletTransactions({ start_date: formatDate(getMonthStart(now)), end_date: formatDate(getMonthEnd(now)), }), ]); // 7つのチェックを順次実行 const allResults: CheckResult[] = []; allResults.push(...checkHighAmount(transactions, config)); allResults.push(...checkDuplicates(transactions, config)); allResults.push(...checkUnapproved(expenseApplications)); allResults.push(...checkBankReconciliation(walletTransactions)); allResults.push( ...checkCategoryAnomalies(transactions, historicalTransactions, config) ); allResults.push( ...checkRecurringPayments(transactions, historicalTransactions, config) ); allResults.push(...checkMissingReceipts(transactions)); // Slack通知 if (envConfig.slackWebhookUrl) { await sendSlackNotification(envConfig.slackWebhookUrl, summary); } }
Promise.allでデータ取得を並列化しているのは、freee APIのレスポンスタイムが1-3秒程度あるためだ。4つのリクエストを直列で投げると最大12秒かかるが、並列化すれば3秒程度で済む。
GitHub Actionsワークフロー
yaml# .github/workflows/freee-expense-check.yml name: freee経費チェック on: schedule: - cron: '0 0 * * *' # 毎日朝9時JST workflow_dispatch: jobs: check: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci working-directory: freee-expense-checker - run: npm start working-directory: freee-expense-checker env: FREEE_CLIENT_ID: ${{ secrets.FREEE_CLIENT_ID }} FREEE_CLIENT_SECRET: ${{ secrets.FREEE_CLIENT_SECRET }} FREEE_REFRESH_TOKEN: ${{ secrets.FREEE_REFRESH_TOKEN }} FREEE_COMPANY_ID: ${{ secrets.FREEE_COMPANY_ID }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
GitHub Actions Secretsで機密情報を管理し、毎朝自動実行する。失敗時はSlackに別途エラー通知を飛ばす。workflow_dispatchを入れておくことで、手動でいつでも実行できるようにもしている。
営業自動化との共通パターン
この経理自動化を営業自動化と並べると、4層モデルの対称性がよく見える。
| 要素 | GTMエンジニアリング(営業) | 社内業務自動化(経理) |
|---|---|---|
| Layer 1: データ収集 | Clay / Apollo → 企業データ | freee API → 取引データ |
| Layer 2: AI処理 | Claude API → ICP適合度スコアリング | 統計分析 → 異常値検出 |
| Layer 3: オーケストレーション | n8n / Make → パイプライン | GitHub Actions → バッチ処理 |
| Layer 4: 出力 | CRM → リードステータス更新 | Slack通知 → 経理アラート |
| 認証パターン | OAuth 2.0(HubSpot) | OAuth 2.0(freee) |
| データ構造 | Company / Contact / Lead | Transaction / WalletTxn / AccountItem |
| 検出ロジック | ICP不一致 / 低スコアリード | 重複 / 高額 / 異常値 |
| 設定管理 | CLAUDE.md(ICP定義) | YAML設定ファイル(閾値) |
Layer 1のデータソースとLayer 4の出力先を差し替えただけで、中間層の設計パターンはほぼ同一だ。
特に注目すべきは「設定管理」の行だ。営業ではCLAUDE.mdにICP定義やスコアリング基準を書いた。経理ではYAML設定ファイルに閾値や除外カテゴリを書いた。どちらも「判断基準を宣言的に外出しし、ロジックから分離する」という同じ設計原則に基づいている。
効果測定
この自動化を3ヶ月運用した結果を共有する。
時間削減
| 作業 | 自動化前 | 自動化後 | 削減率 |
|---|---|---|---|
| 月次の経費レビュー | 4-5時間/月 | 30分/月(アラート対応のみ) | 88% |
| 銀行明細の消込 | 2-3時間/月 | 1時間/月(未処理リストの確認) | 58% |
| 証憑の添付確認 | 1-2時間/月 | 15分/月(未添付リストの処理) | 83% |
| Amazon購入の仕訳 | 2-3時間/月 | 10分/月(スクリプト実行+確認) | 93% |
| 合計 | 9-13時間/月 | 約2時間/月 | 80%+ |
月に10時間程度の経理作業が2時間に圧縮された。年間にすると100時間近い削減になる。
検出精度
| チェック種別 | 初月の誤検出率 | 3ヶ月後の誤検出率 |
|---|---|---|
| 高額経費 | 15%(家賃を含めていた) | 2%(除外カテゴリ調整後) |
| 重複経費 | 20%(定期支払いを重複扱い) | 5%(tolerance調整後) |
| カテゴリ異常値 | 30%(季節変動を考慮せず) | 10%(データ蓄積で安定) |
| 定期支払い欠損 | 10%(支払日ずれ) | 3%(tolerance_days調整後) |
| 証憑未添付 | 5% | 2% |
運用開始直後は誤検出が多い。特にカテゴリ異常値チェックは、過去データの蓄積が3ヶ月を超えたあたりから安定し始める。設定ファイルのパラメータチューニングを繰り返すことで、実用的な精度に到達した。
見落としていた問題の発見
自動化によって初めて気づいた問題もある。
- 同一SaaSの二重課金が2件見つかった(プラン変更時の旧プランが解約されていなかった)
- 解約済みサービスの請求が1件継続していた
- Amazonの備品購入で証憑が3ヶ月分未添付だった
どれも手動の月次レビューでは見落としていたものだ。特に二重課金はSlackのフリープランからプロプランへの移行時に起きていて、月額で数千円の無駄が3ヶ月続いていた。自動化の初期投資を考えても、この検出だけで元が取れている。
設定ファイル駆動の設計
チェックの閾値や除外条件はすべてYAML設定ファイルで管理している。
yaml# config/check-config.yaml thresholds: high_amount: warning: 50000 critical: 100000 duplicate: days: 30 amount_tolerance: 0 category_anomaly: std_deviation: 2.0 recurring_check: months: 3 tolerance_days: 5 categories: exclude: [] high_amount_exempt: - "地代家賃" - "給料賃金" recurring_patterns: - name: "AWS" partner_contains: ["Amazon Web Services", "AWS"] expected_day_range: [1, 5] - name: "GitHub" partner_contains: ["GitHub"] expected_day_range: [15, 20]
この設計のメリットは、チェックロジックのコードを一切触らずに閾値や挙動を調整できることだ。freeeの勘定科目体系は事業所ごとに違うし、「何が高額か」の基準も組織規模で全く異なる。設定ファイルを差し替えるだけで別の事業所に適用できる。
他の社内業務への展開
この経理自動化で得たパターンは、他の業務領域にもそのまま使える。
- 人事: 勤怠データAPIから異常な残業パターンを検出
- カスタマーサクセス: Intercom/Zendesk APIからチケット分析、解約予兆の検出
- マーケティング: Google Analytics APIからコンバージョンの異常値検出
- 法務: 契約書の更新期限チェック
いずれも4層モデルの「Layer 1のAPI」と「Layer 4の通知先」を差し替えるだけで実装できる。GTMエンジニアのスキルセットで社内業務を片付けた経験は、そのまま顧客への提案ネタになる。「自社でこの仕組みを使って月10時間の経理作業を2時間にした」という実績は、どんな営業トークより説得力がある。
次回はGTMエンジニアのキャリアパスを取り上げて、このシリーズを締めくくる。GTMエンジニアリングという職能が日本のSaaS市場でどう発展していくか、ここまでの記事で見てきた技術スタック・実践事例・社内適用の全体像を踏まえて考察する。