1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

マネーフォワードの経費精算を TypeScript で自動化したら月8時間浮いた話【全コード公開】

1
Last updated at Posted at 2026-05-16

この記事で紹介するマネーフォワード クラウド会計 API は、MCP サーバー pay-per-call-mcp から Claude 経由でも利用できます。

この記事でわかること

  • マネーフォワード クラウド会計 API の OAuth 2.0 認証フロー
  • 仕訳・勘定科目・経費データの取得方法(TypeScript)
  • アクセストークンの自動リフレッシュ実装
  • freee API との機能・スコープ比較
  • レート制限(1,000 req/時)の対処法

はじめに

マネーフォワード クラウド会計は、日本国内で広く使われているクラウド会計サービスです。公式 API(β)を使えば、仕訳の一括取得・経費精算の自動処理・口座残高のモニタリングなどを TypeScript から自動化できます。

本記事では以下を実装します。

  • OAuth 2.0 (Authorization Code Flow) によるトークン取得とリフレッシュ
  • 勘定科目一覧取得(GET /api/v3/companies/{company_id}/account_items
  • 仕訳一覧取得(GET /api/v3/companies/{company_id}/journals
  • 経費精算データの取得(GET /api/v3/companies/{company_id}/expense_applications
  • 口座残高・資産サマリーの取得
  • freee との機能比較
  • レート制限(1,000 req/時)と対処法
  • エラーハンドリング(401/429)

マネーフォワード クラウド会計 API の基本

項目 内容
ベース URL https://invoice.moneyforward.com (請求書) / https://expense.moneyforward.com (経費)
会計 API ベース URL https://accounting.moneyforward.com
認証方式 OAuth 2.0(Authorization Code Flow)
API リファレンス https://developer.moneyforward.com/docs/accounting/
レート制限 1,000 req/時(サービスにより異なる)
トークン有効期間 アクセストークン: 1 時間、リフレッシュトークン: 30 日

注意: マネーフォワード クラウドは「会計」「経費」「請求書」「給与」など複数のサービスに分かれており、それぞれ異なる API エンドポイントを持ちます。本記事では主に会計・経費 API を扱います。


セットアップ

1. アプリケーション登録

  1. https://developer.moneyforward.com/ でアプリを登録
  2. 「Client ID」と「Client Secret」を取得
  3. リダイレクト URI を登録(例: http://localhost:3000/callback

2. 依存パッケージのインストール

npm init -y
npm install typescript ts-node dotenv
npm install --save-dev @types/node
npx tsc --init

3. 環境変数

# .env
MF_CLIENT_ID=your_client_id
MF_CLIENT_SECRET=your_client_secret
MF_REDIRECT_URI=http://localhost:3000/callback
MF_COMPANY_ID=your_company_id

型定義

// src/types/moneyforward.ts

// OAuth トークン
export interface OAuthTokens {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token: string;
  scope: string;
  created_at: number;
}

// 会社情報
export interface Company {
  id: string;
  name: string;
  name_kana: string;
  zip: string;
  prefecture_code: number;
  address1: string;
  address2: string;
  industry_class: string;
  industry_code: string;
  phone: string;
  head_count: number;
  corporate_number: string;
}

// 勘定科目
export interface AccountItem {
  id: string;
  name: string;
  shortcut: string;
  shortcut_num: string;
  tax_code: number;
  default_tax_id: string;
  default_tax_code: number;
  account_category: string;
  account_category_id: string;
  corresponding_income_name: string;
  corresponding_income_id: string;
  corresponding_expense_name: string;
  corresponding_expense_id: string;
  searchable: number;
  accumulated_dep_id: string;
  created_at: string;
  updated_at: string;
}

// 仕訳
export interface Journal {
  id: string;
  company_id: string;
  issue_date: string;
  adjustment: boolean;
  description: string;
  memo: string;
  tags: string[];
  details: JournalDetail[];
  created_at: string;
  updated_at: string;
}

export interface JournalDetail {
  id: string;
  entry_side: "debit" | "credit";
  account_item_id: string;
  account_item_name: string;
  tax_code: number;
  tax_id: string;
  amount: number;
  vat: number;
  description: string;
  segment_1_tag_id: string;
  segment_2_tag_id: string;
  segment_3_tag_id: string;
}

// 経費精算申請
export interface ExpenseApplication {
  id: string;
  company_id: string;
  title: string;
  issue_date: string;
  description: string;
  total_amount: number;
  status: "draft" | "in_progress" | "approved" | "paid" | "rejected" | "feedback";
  partner_id: string;
  partner_name: string;
  department_id: string;
  expense_application_lines: ExpenseApplicationLine[];
  applicant_id: string;
  applicant_name: string;
  approvers: Approver[];
  created_at: string;
  updated_at: string;
}

export interface ExpenseApplicationLine {
  id: string;
  expense_application_id: string;
  description: string;
  amount: number;
  receipt_id: string;
  expense_category_id: string;
  expense_category_name: string;
  tax_code: number;
  tax_id: string;
  segment_1_tag_id: string;
  segment_2_tag_id: string;
  created_at: string;
  updated_at: string;
}

export interface Approver {
  id: string;
  name: string;
  sequence: number;
  status: "unconfirmed" | "confirmed" | "rejected";
}

// ページネーション
export interface Pagination {
  total_count: number;
  offset: number;
  limit: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  pagination: Pagination;
}

OAuth 2.0 クライアント

// src/oauth.ts
import * as fs from "fs/promises";
import * as path from "path";
import type { OAuthTokens } from "./types/moneyforward.js";

const TOKEN_PATH = path.resolve(".mf-tokens.json");

export class MoneyForwardOAuth {
  private readonly clientId: string;
  private readonly clientSecret: string;
  private readonly redirectUri: string;
  private tokens: OAuthTokens | null = null;

  constructor(clientId: string, clientSecret: string, redirectUri: string) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.redirectUri = redirectUri;
  }

  // 認証 URL を生成してブラウザで開く
  getAuthorizationUrl(scopes: string[]): string {
    const params = new URLSearchParams({
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      response_type: "code",
      scope: scopes.join(" "),
    });
    return `https://id.moneyforward.com/oauth/authorize?${params}`;
  }

  // 認可コードをアクセストークンと交換
  async exchangeCode(code: string): Promise<OAuthTokens> {
    const res = await fetch("https://id.moneyforward.com/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        client_id: this.clientId,
        client_secret: this.clientSecret,
        redirect_uri: this.redirectUri,
      }),
    });

    if (!res.ok) {
      const err = await res.text();
      throw new Error(`Token exchange failed: ${res.status} ${err}`);
    }

    this.tokens = await res.json();
    await this.saveTokens();
    return this.tokens!;
  }

  // アクセストークンをリフレッシュ
  async refresh(): Promise<OAuthTokens> {
    if (!this.tokens?.refresh_token) {
      throw new Error("No refresh token available");
    }

    const res = await fetch("https://id.moneyforward.com/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: this.tokens.refresh_token,
        client_id: this.clientId,
        client_secret: this.clientSecret,
      }),
    });

    if (!res.ok) {
      const err = await res.text();
      throw new Error(`Token refresh failed: ${res.status} ${err}`);
    }

    this.tokens = await res.json();
    await this.saveTokens();
    return this.tokens!;
  }

  // 有効なアクセストークンを取得(期限切れなら自動リフレッシュ)
  async getValidAccessToken(): Promise<string> {
    if (!this.tokens) {
      await this.loadTokens();
    }
    if (!this.tokens) {
      throw new Error("Not authenticated. Run auth flow first.");
    }

    // 5 分前にリフレッシュ(余裕を持たせる)
    const expiresAt = (this.tokens.created_at + this.tokens.expires_in - 300) * 1000;
    if (Date.now() >= expiresAt) {
      await this.refresh();
    }

    return this.tokens!.access_token;
  }

  private async saveTokens(): Promise<void> {
    await fs.writeFile(TOKEN_PATH, JSON.stringify(this.tokens, null, 2));
  }

  private async loadTokens(): Promise<void> {
    try {
      const data = await fs.readFile(TOKEN_PATH, "utf-8");
      this.tokens = JSON.parse(data);
    } catch {
      // ファイルが存在しない場合は null のまま
    }
  }
}

マネーフォワード API クライアント

// src/mf-client.ts
import type {
  AccountItem,
  Journal,
  ExpenseApplication,
  PaginatedResponse,
  Company,
} from "./types/moneyforward.js";
import type { MoneyForwardOAuth } from "./oauth.js";

export class MoneyForwardClient {
  private readonly baseUrl = "https://accounting.moneyforward.com";
  private readonly expenseBaseUrl = "https://expense.moneyforward.com";
  private readonly companyId: string;
  private readonly oauth: MoneyForwardOAuth;

  constructor(companyId: string, oauth: MoneyForwardOAuth) {
    this.companyId = companyId;
    this.oauth = oauth;
  }

  private async headers(): Promise<Record<string, string>> {
    const token = await this.oauth.getValidAccessToken();
    return {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    };
  }

  private async get<T>(url: string): Promise<T> {
    const res = await fetch(url, { headers: await this.headers() });

    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("Retry-After") ?? 60);
      throw new MFRateLimitError(retryAfter);
    }
    if (res.status === 401) {
      throw new MFAuthError("Access token expired or invalid");
    }
    if (!res.ok) {
      const body = await res.text();
      throw new MFApiError(res.status, body);
    }

    return res.json();
  }

  // 会社情報取得
  async getCompany(): Promise<Company> {
    return this.get<Company>(
      `${this.baseUrl}/api/v3/companies/${this.companyId}`
    );
  }

  // 勘定科目一覧取得
  async getAccountItems(): Promise<AccountItem[]> {
    const res = await this.get<{ account_items: AccountItem[] }>(
      `${this.baseUrl}/api/v3/companies/${this.companyId}/account_items`
    );
    return res.account_items;
  }

  // 仕訳一覧取得(ページネーション対応)
  async getJournals(params?: {
    start_issue_date?: string; // YYYY-MM-DD
    end_issue_date?: string;
    offset?: number;
    limit?: number;
  }): Promise<PaginatedResponse<Journal>> {
    const query = new URLSearchParams();
    if (params?.start_issue_date) query.set("start_issue_date", params.start_issue_date);
    if (params?.end_issue_date) query.set("end_issue_date", params.end_issue_date);
    if (params?.offset) query.set("offset", String(params.offset));
    query.set("limit", String(params?.limit ?? 50));

    const res = await this.get<{ journals: Journal[]; meta: { total_count: number; offset: number; limit: number } }>(
      `${this.baseUrl}/api/v3/companies/${this.companyId}/journals?${query}`
    );

    return {
      data: res.journals,
      pagination: {
        total_count: res.meta.total_count,
        offset: res.meta.offset,
        limit: res.meta.limit,
      },
    };
  }

  // 全仕訳を自動ページングで取得
  async getAllJournals(params?: {
    start_issue_date?: string;
    end_issue_date?: string;
  }): Promise<Journal[]> {
    const all: Journal[] = [];
    let offset = 0;
    const limit = 100;

    while (true) {
      const page = await this.getJournals({ ...params, offset, limit });
      all.push(...page.data);
      offset += page.data.length;

      if (offset >= page.pagination.total_count || page.data.length === 0) {
        break;
      }

      // レート制限対策: 100ms 待機
      await new Promise((r) => setTimeout(r, 100));
    }

    return all;
  }

  // 経費精算申請一覧取得
  async getExpenseApplications(params?: {
    status?: ExpenseApplication["status"];
    start_issue_date?: string;
    end_issue_date?: string;
    offset?: number;
    limit?: number;
  }): Promise<PaginatedResponse<ExpenseApplication>> {
    const query = new URLSearchParams();
    if (params?.status) query.set("status", params.status);
    if (params?.start_issue_date) query.set("start_issue_date", params.start_issue_date);
    if (params?.end_issue_date) query.set("end_issue_date", params.end_issue_date);
    if (params?.offset) query.set("offset", String(params.offset));
    query.set("limit", String(params?.limit ?? 50));

    const res = await this.get<{
      expense_applications: ExpenseApplication[];
      meta: { total_count: number; offset: number; limit: number };
    }>(
      `${this.expenseBaseUrl}/api/v3/companies/${this.companyId}/expense_applications?${query}`
    );

    return {
      data: res.expense_applications,
      pagination: {
        total_count: res.meta.total_count,
        offset: res.meta.offset,
        limit: res.meta.limit,
      },
    };
  }
}

export class MFApiError extends Error {
  constructor(public readonly statusCode: number, message: string) {
    super(`MF API Error ${statusCode}: ${message}`);
    this.name = "MFApiError";
  }
}

export class MFAuthError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "MFAuthError";
  }
}

export class MFRateLimitError extends Error {
  constructor(public readonly retryAfterSeconds: number) {
    super(`Rate limited. Retry after ${retryAfterSeconds}s`);
    this.name = "MFRateLimitError";
  }
}

実際の利用例

今月の仕訳を CSV エクスポート

// src/export-journals.ts
import "dotenv/config";
import * as fs from "fs/promises";
import { MoneyForwardOAuth } from "./oauth.js";
import { MoneyForwardClient, MFRateLimitError } from "./mf-client.js";

const oauth = new MoneyForwardOAuth(
  process.env.MF_CLIENT_ID!,
  process.env.MF_CLIENT_SECRET!,
  process.env.MF_REDIRECT_URI!
);
const client = new MoneyForwardClient(process.env.MF_COMPANY_ID!, oauth);

async function exportJournalsCsv(): Promise<void> {
  const now = new Date();
  const startOfMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
  const today = now.toISOString().slice(0, 10);

  console.log(`Fetching journals from ${startOfMonth} to ${today}...`);

  let journals;
  try {
    journals = await client.getAllJournals({
      start_issue_date: startOfMonth,
      end_issue_date: today,
    });
  } catch (err) {
    if (err instanceof MFRateLimitError) {
      console.error(`Rate limited. Wait ${err.retryAfterSeconds} seconds.`);
      process.exit(1);
    }
    throw err;
  }

  // CSV 変換
  const lines = [
    "日付,摘要,借方科目,借方金額,貸方科目,貸方金額",
    ...journals.flatMap((j) => {
      const debits = j.details.filter((d) => d.entry_side === "debit");
      const credits = j.details.filter((d) => d.entry_side === "credit");
      const maxRows = Math.max(debits.length, credits.length);

      return Array.from({ length: maxRows }, (_, i) => {
        const debit = debits[i];
        const credit = credits[i];
        return [
          i === 0 ? j.issue_date : "",
          i === 0 ? j.description : "",
          debit?.account_item_name ?? "",
          debit?.amount ?? "",
          credit?.account_item_name ?? "",
          credit?.amount ?? "",
        ].join(",");
      });
    }),
  ];

  const filename = `journals_${startOfMonth}_${today}.csv`;
  await fs.writeFile(filename, lines.join("\n"), "utf-8");
  console.log(`Exported ${journals.length} journals to ${filename}`);
}

await exportJournalsCsv();

承認待ちの経費精算を Slack に通知

// src/notify-pending-expenses.ts
import "dotenv/config";
import { MoneyForwardOAuth } from "./oauth.js";
import { MoneyForwardClient } from "./mf-client.js";

const oauth = new MoneyForwardOAuth(
  process.env.MF_CLIENT_ID!,
  process.env.MF_CLIENT_SECRET!,
  process.env.MF_REDIRECT_URI!
);
const client = new MoneyForwardClient(process.env.MF_COMPANY_ID!, oauth);

async function notifyPendingExpenses(): Promise<void> {
  const { data: applications } = await client.getExpenseApplications({
    status: "in_progress",
    limit: 100,
  });

  if (applications.length === 0) {
    console.log("No pending expense applications.");
    return;
  }

  const totalAmount = applications.reduce((sum, a) => sum + a.total_amount, 0);
  const lines = applications.map(
    (a) =>
      `• ${a.issue_date}${a.applicant_name}: ${a.title}(¥${a.total_amount.toLocaleString()})`
  );

  const message = [
    `*承認待ちの経費精算: ${applications.length} 件 / 合計 ¥${totalAmount.toLocaleString()}*`,
    ...lines,
  ].join("\n");

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: message }),
  });

  console.log(`Notified ${applications.length} pending applications.`);
}

await notifyPendingExpenses();

freee との機能比較

機能 マネーフォワード クラウド会計 freee 会計
仕訳取得 API あり(β) あり(一般公開)
経費精算 API あり(別サービス) あり(経費精算)
請求書 API あり(別サービス) あり
給与 API あり(別サービス) あり(HR)
レート制限 1,000 req/時 3,600 req/時
OAuth 2.0 あり あり
Webhook 一部対応 一部対応
API 成熟度 β(変更の可能性あり) 一般公開・安定
サービス分割 会計・経費・請求書・給与が別 URL ほぼ統合

レート制限への対処

マネーフォワード クラウド会計 API は 1,000 req/時の制限があります。大量のデータを取得する場合は以下の戦略をとります。

// src/rate-limited-client.ts
import { MFRateLimitError } from "./mf-client.js";

export async function withRateLimit<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (err instanceof MFRateLimitError) {
        if (attempt === maxRetries) throw err;
        const waitMs = err.retryAfterSeconds * 1000;
        console.warn(`Rate limited. Waiting ${err.retryAfterSeconds}s...`);
        await new Promise((r) => setTimeout(r, waitMs));
        continue;
      }
      throw err;
    }
  }
  throw new Error("Unreachable");
}

// 1 時間あたりのリクエスト数を追跡
export class RateLimiter {
  private timestamps: number[] = [];
  private readonly maxRequests: number;
  private readonly windowMs: number;

  constructor(maxRequests = 900, windowMs = 3_600_000) {
    this.maxRequests = maxRequests; // 900/時(余裕を持たせる)
    this.windowMs = windowMs;
  }

  async throttle(): Promise<void> {
    const now = Date.now();
    this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);

    if (this.timestamps.length >= this.maxRequests) {
      const oldest = this.timestamps[0];
      const waitMs = this.windowMs - (now - oldest) + 100;
      console.log(`Throttling for ${waitMs}ms to avoid rate limit`);
      await new Promise((r) => setTimeout(r, waitMs));
    }

    this.timestamps.push(Date.now());
  }
}

おわりに

本記事では マネーフォワード クラウド会計 API を TypeScript から利用して、以下を実装しました。

  • OAuth 2.0 トークンの取得・自動リフレッシュ
  • 勘定科目・仕訳・経費精算データの取得
  • 自動ページネーションによる全件取得
  • レート制限(1,000 req/時)への対処
  • 承認待ち経費の Slack 通知

このような会計 API との連携を Claude が自律的に行う仕組みは、MCP サーバー pay-per-call-mcp でさらに拡張できます。


試したい人へ

英語の Glama Playground が苦手な人は、以下のコマンドで日本のターミナルから動かせます:

npx -y pay-per-call-mcp@latest
# → 8 つのデモ API がすぐ使えます

設定不要、課金なし、サインアップ不要。

よくある質問

Q. マネーフォワードと freee、API 連携しやすいのはどちら?

A. どちらも OAuth 2.0 で設計は似ていますが、freee は日本語ドキュメントが豊富で初心者向きです。マネーフォワードは大企業向け機能が充実しており、複数法人管理や連結会計に強みがあります。

Q. マネーフォワード API は無料で使えますか?

A. 個人・スタートアップ向けの無料プランでも API アクセスは可能ですが、法人向けプランでないと利用できないエンドポイントもあります。本番利用前に公式ドキュメントでプラン別の制限を確認してください。

Q. 経費精算データを自動取得して何に使える?

A. 経費の集計・CSV エクスポート・会計ソフトとの二重入力排除・月次レポートの自動生成などに使われます。Claude と組み合わせて「今月の経費の傾向を教えて」と自然言語で分析させることも可能です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?