🐈

MFクラウド請求書APIを使ってコマンド1発で請求書を発行しよう

2024/12/05に公開

https://qiita.com/advent-calendar/2024/puromoku

はじめに

個人事業主の皆さん、先月分の請求書はもう発行しましたか?
僕も毎月マネーフォワードのクラウド請求書をポチポチして副業先への請求書を作ってましたが、こういうルーチンワークはなるべく手間をかけたくないのがエンジニアの性ですよね。

そう思って調べてみると、クラウド請求書のAPIが充実していたので自動化してみることにしました。

https://biz.moneyforward.com/support/invoice/guide/api-guide/a03.html

リポジトリはこちら

https://github.com/shu-illy/mf_invoice_creator

API利用登録

まずはAPIを利用するためにアプリの登録を行います。
クラウド請求書の開発者向けページからアプリ登録に進みます。(MFクラウドのアカウントが必要です)

こちらのチュートリアルに従ってアプリを登録してください。

https://developers.biz.moneyforward.com/docs/tutorial/step-1

新規登録画面にてリダイレクトURIの入力が求められますが、localhostと適当なポート・パスでOKです。
(リダイレクト時のパラメータが必要なだけなので、対応するページが存在しなくても問題ありません)

プロジェクト作成

言語は勿論なんでも良いですが、ここではRubyを使うことにします。
適当なディレクトリで以下のGemfileを用意します。

mkdir mf_invoice_creator && cd mf_invoice_creator
touch Gemfile
Gemfile
source 'https://rubygems.org'

ruby '>= 3.2.2'

gem 'httparty' # API通信に使う
gem 'dotenv' # 環境変数設定に使う
bundle install

APIを使った請求書作成の流れは以下の通りです。
見ての通りOAuth2.0による認可フローになります。

  1. [初回のみ] 認可エンドポイント(GET https://api.biz.moneyforward.com/authorize)にアクセスして認可コードを取得
  2. [初回のみ] 認可コードを使ってトークンエンドポイント(POST https://api.biz.moneyforward.com/token)にアクセスし、アクセストークン・リフレッシュトークンを取得
  3. アクセストークンが期限切れの場合はリフレッシュトークンを使ってトークンエンドポイントにアクセスし、アクセストークン・リフレッシュトークンを取得
  4. アクセストークンを使って請求書作成APIにアクセスし、請求書を作成
  5. 請求書をダウンロード
  6. 請求書ステータスを「下書き」から「未入金」に更新

順番に実装していきます。

初回認証

  • 認可エンドポイントにアクセスして認可コードを取得
  • 認可コードを使ってトークンエンドポイントにアクセスし、アクセストークン・リフレッシュトークンを取得

を行うクラスを作成します。
環境変数はそれぞれOAuth2.0に使用するパラメータなので、適宜.envに設定します。

.env
CLIENT_ID=xxxxxxxxxxxx # クラウド請求書のアプリポータルから手おt句
CLIENT_SECRET=xxxxxxxxxxxx # クラウド請求書のアプリポータルから手おt句
REDIRECT_URI=http://localhost:12345/callback
AUTHORIZATION_ENDPOINT=https://api.biz.moneyforward.com/authorize
SCOPE=mfc/invoice/data.read+mfc/invoice/data.write
TOKEN_ENDPOINT=https://api.biz.moneyforward.com/token
CODE_VERIFIER=xxxxxxxxxxxx # 任意のアルファベット、数字、-、.、_、~から43~128文字
initial_auth.rb
require 'httparty'
require 'json'
require 'dotenv/load'
require 'fileutils'
require 'base64'
require 'digest'

class InitialAuth
  def initialize
    @token_endpoint = ENV['TOKEN_ENDPOINT']
    @client_id = ENV['CLIENT_ID']
    @client_secret = ENV['CLIENT_SECRET']
    @authorization_endpoint = ENV['AUTHORIZATION_ENDPOINT']
    @redirect_uri = ENV['REDIRECT_URI']
    @scope = ENV['SCOPE']
    @code_verifier = ENV['CODE_VERIFIER']
    if @token_endpoint.nil? || @token_endpoint.empty? ||
      @client_id.nil? || @client_id.empty? ||
      @client_secret.nil? || @client_secret.empty? ||
      @redirect_uri.nil? || @redirect_uri.empty? ||
      @authorization_endpoint.nil? || @authorization_endpoint.empty?
      puts "Error: TOKEN_ENDPOINT, CLIENT_ID, CLIENT_SECRET, AUTHORIZATION_ENDPOINT, SCOPE, CODE_VERIFIER, and REDIRECT_URI must be set in .env"
      exit 1
    end
  end

  def get_authorization_code
    code_verifier = ENV['CODE_VERIFIER']
    code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).gsub('=', '')
    authorization_url = "#{@authorization_endpoint}?" +
      "response_type=code&" +
      "client_id=#{@client_id}&" +
      "redirect_uri=#{@redirect_uri}&" +
      "scope=#{@scope}&" +
      "state=#{SecureRandom.hex(5)}&" +
      "code_challenge=#{code_challenge}&" +
      "code_challenge_method=S256"

    # 認証コードを取得
    puts "以下のURLにアクセスして連携を許可してください。"
    puts authorization_url
    print "許可後にリダイレクトされたページのURLを貼り付けてください。"
    print "リダイレクトURL: "
    @authorization_code = Hash[URI::decode_www_form(URI.parse(gets.chomp).query)]['code']
  end

  def save_token
    # Basic認証ヘッダーの作成
    basic_auth = Base64.strict_encode64("#{@client_id}:#{@client_secret}")

    response = HTTParty.post(@token_endpoint, {
      body: {
        grant_type: 'authorization_code',
        code: @authorization_code,
        redirect_uri: @redirect_uri,
        code_verifier: @code_verifier
      },
      headers: {
        'Content-Type' => 'application/x-www-form-urlencoded',
        'Authorization' => "Basic #{basic_auth}"
      }
    })

    if response.success?
      data = JSON.parse(response.body)
      tokens = {
        'access_token' => data['access_token'],
        'refresh_token' => data['refresh_token'],
        'expires_at' => Time.now.to_i + data['expires_in'].to_i
      }
      File.open('tokens.json', 'w') do |file|
        file.write(JSON.pretty_generate(tokens))
      end
      puts "認証に成功しました。tokens.jsonを更新しました。"
      return tokens
    else
      puts "認証に失敗しました。ステータスコード: #{response.code}"
      puts "メッセージ: #{response.body}"
    end
  rescue StandardError => e
    puts "例外が発生しました: #{e.message}"
  end
end

get_authorization_code メソッドでは、ユーザーに認可エンドポイントへのアクセスを促し、リダイレクトURLを入力させることで認可コードを取得します。
save_token メソッドでは、get_authorization_code メソッドで取得した認可コードを使ってトークンエンドポイントにアクセスし、アクセストークン, リフレッシュトークン, アクセストークン有効期限をtokens.json ファイルに保存します。(勿論このjsonはgit管理から外さないといけません)

認可情報の管理

次に、認可情報を管理するクラスを作成します。
上述のtokens.jsonファイルを見て、必要に応じてInitialAuthクラスを呼んで初回認証を実行したり、アクセストークンを再発行したりします。
InitialAuthクラスは基本的にこのクラスからのみ呼ばれることになります。

token_manager.rb
require 'httparty'
require 'json'
require 'dotenv/load'
require 'fileutils'
require 'base64'
require 'pry'
require './initial_auth'

class TokenManager
  TOKEN_FILE = 'tokens.json'.freeze

  def initialize
    @client_id = ENV['CLIENT_ID'].freeze
    @client_secret = ENV['CLIENT_SECRET'].freeze
    @token_endpoint = ENV['TOKEN_ENDPOINT'].freeze
    raise "CLIENT_ID is not set in .env" if @client_id.nil? || @client_id.empty?
    raise "CLIENT_SECRET is not set in .env" if @client_secret.nil? || @client_secret.empty?

    # トークン読み込み
    if File.exist?(TOKEN_FILE)
      file_content = File.read(TOKEN_FILE)
      @tokens = JSON.parse(file_content)
    else
      @tokens = { 'access_token' => '', 'refresh_token' => '', 'expires_at' => 0 }
      save_tokens
    end
  end

  # アクセストークンを取得(必要に応じて更新)
  def access_token
    # 初回認証
    if @tokens['access_token'].nil? || @tokens['access_token'].empty?
      inital_auth = InitialAuth.new
      inital_auth.get_authorization_code
      @tokens = inital_auth.save_token
      return @tokens['access_token']
    end
    
    if access_token_expired?
      refresh_access_token
    else
      @tokens['access_token']
    end
  end

  private

  # トークンファイルを保存する
  def save_tokens
    File.open(TOKEN_FILE, 'w') do |file|
      file.write(JSON.pretty_generate(@tokens))
    end
  end

  def initial_authorization?

  end
  
  # アクセストークンが期限切れかどうかを確認する
  def access_token_expired?
    current_time = Time.now.to_i
    @tokens['access_token'].nil? || @tokens['access_token'].empty? || current_time >= @tokens['expires_at'].to_i
  end

  # リフレッシュトークンを使用してアクセストークンを更新する
  def refresh_access_token
    if @tokens['refresh_token'].nil? || @tokens['refresh_token'].empty?
      raise "Refresh token is missing. Please authenticate first."
    end

    body = {
      grant_type: 'refresh_token',
      redirect_uri: ENV['REDIRECT_URI'],
      refresh_token: @tokens['refresh_token'],
      code_verifier: ENV['CODE_VERIFIER']
    }
    response = HTTParty.post(@token_endpoint, {
      body:,
      headers: {
        'Content-Type' => 'application/x-www-form-urlencoded',
        'Authorization' => "Basic #{Base64.strict_encode64("#{@client_id}:#{@client_secret}")}"
      }
    })

    if response.success?
      data = JSON.parse(response.body)
      @tokens['access_token'] = data['access_token']
      @tokens['refresh_token'] = data['refresh_token'] # 新しいリフレッシュトークンに更新
      @tokens['expires_at'] = Time.now.to_i + data['expires_in'].to_i
      save_tokens
      puts "アクセストークンが更新されました。"
      @tokens['access_token']
    else
      puts "アクセストークンの更新に失敗しました。ステータスコード: #{response.code}"
      puts "メッセージ: #{response.body}"
      raise "Failed to refresh access token."
    end
  end
end

請求書作成

上記のアクセストークンを使って請求書を作成するクラスを作成します。

作成した請求書のステータスは初期状態の「下書き」になっていますが、この状態ではクラウド確定申告で仕訳に取り込めないので、update_to_before_billing メソッドでステータスの更新もできるようにします。

invoice_creator.rb
require 'httparty'
require 'json'
require 'fileutils'
require_relative 'token_manager'

class InvoiceCreator
  API_URL = 'https://invoice.moneyforward.com/api/v3/invoice_template_billings'.freeze

  def initialize
    token_manager = TokenManager.new
    @access_token = token_manager.access_token
  end

  #
  # 請求書を作成する
  #
  # @param [Hash] params 請求書作成の各種パラメータ
  #
  # @return [Hash] 請求書作成APIのレスポンスボディ
  #
  def create_invoice(params)
    headers = {
      "Content-Type" => "application/json",
      "Accept" => "application/json",
      "Authorization" => "Bearer #{@access_token}"
    }

    response = HTTParty.post(API_URL, headers: headers, body: params.to_json)

    if response.success?
      invoice = JSON.parse(response.body)
      invoice_id = invoice['id']
      puts "請求書が正常に作成されました。請求書ID: #{invoice_id}"
      invoice
    else
      puts "エラーが発生しました。ステータスコード: #{response.code}"
      puts "メッセージ: #{response.body}"
      nil
    end
  rescue StandardError => e
    puts "例外が発生しました: #{e.message}"
    nil
  end

  #
  # 請求書のPDFをダウンロードする
  #
  # @param [String] pdf_url PDFのURL
  # @param [String] pdf_path PDF保存先のパス
  #
  def download_pdf(pdf_url, pdf_path)
    headers = {
      "Authorization" => "Bearer #{@access_token}"
    }
    pdf_url = "#{pdf_url}"

    response = HTTParty.get(pdf_url, headers: headers)

    if response.success?
      pdf_data = response.body
      FileUtils.mkdir_p(File.dirname(pdf_path))
      File.open(pdf_path, 'wb') do |file|
        file.write(pdf_data)
      end
      puts "請求書PDFが保存されました: #{pdf_path}"
    else
      puts "PDFのダウンロードに失敗しました。ステータスコード: #{response.code}"
      puts "メッセージ: #{response.body}"
    end
  rescue StandardError => e
    puts "例外が発生しました: #{e.message}"
  end

  #
  # 請求書のステータスを「未入金」に変更する
  #
  # @param [String] billing_id <description>
  #
  def update_to_before_billing(billing_id)
    url = "https://invoice.moneyforward.com/api/v3/billings/#{billing_id}/payment_status".freeze

    headers = {
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{@access_token}"
    }
    response = HTTParty.put(url, headers: headers, body: { payment_status: '1' }.to_json)
    if response.success?
      puts 'ステータスが未入金に変更されました'
    else
      puts "ステータス更新に失敗しました。ステータスコード: #{response.code}"
      puts "メッセージ: #{response.body}"
    end
  rescue StandardError => e
    puts "例外が発生しました: #{e.message}"
  end
end

請求書作成スクリプト

ここまでで請求書の作成・ダウンロードができるようになりました。
しかし、この状態では逐一パラメータを設定して実行しないといけないため、「1コマンドでらくらく発行」というには程遠い状態です。
パラメータの設定まで含めて1コマンドで実行できるようにしたいですね。

そこで、パラメータを設定した上でInvoiceCreatorクラスを呼ぶスクリプトを作成します。

エンジニアの業務委託であれば、売上は「月単価」または「時間単価 x 稼働時間」で計算されることが多いと思います。
単価に関しては取引先に応じて固定値なので、取引先ごとにパラメータをカスタマイズしたスクリプトを用意すれば、1コマンドで済ませられるはずです。

というわけで、スクリプトの雛形と、雛形をカスタマイズした取引先ごとのスクリプトを同じディレクトリ内に配置します。
雛形以外はgit管理から除外するのも忘れずに。

tree
.
├── Gemfile
├── Gemfile.lock
├── README.md
├── initial_auth.rb
├── invoice_creator.rb
├── patterns ← このディレクトリに配置する
│   ├── partner_a.rb ← 取引先A用のスクリプト
│   ├── partner_b.rb ← 取引先A用のスクリプト
│   └── sample.rb ← 雛形
├── token_manager.rb
└── tokens.json
.gitignore
.env

.DS_Store

tokens.json

patterns/*
!patterns/
!patterns/sample.rb
sample.rb
require 'yaml'
require 'dotenv/load'
require 'optparse'
require 'date'
require_relative '../invoice_creator'

# 下記の変数の値を編集する
# -----------------------------------
department_id = 'xxxxxxxxxx'
billing_date = Date.today.strftime('%Y-%m-%d') # 請求日(例: 今日の日付)
due_date = Date.new(Date.today.year, Date.today.month, -1).strftime('%Y-%m-%d') # 振込期限(例: 今月末)
sales_date = Date.new(Date.today.last_month, Time.now.month, -1).strftime('%Y-%m-%d') # 売上計上日(例: 先月末)
item_name = 'システム開発費用' # 品目名
item_price = 10000 # 単価
download_path = '/path/to/download.pdf' # 請求書PDFの保存先
consumption_tax_display_type = 'internal' # 内税 or 外税('internal' or 'external')
# -----------------------------------

# コマンドの引数でパラメータを渡したい場合は、以下のコメントアウトを外す
# -----------------------------------
# options = {}
# OptionParser.new do |opts|
#   opts.banner = "Usage: sample.rb [options]"
#
#   opts.on("-q", "--quantity ITEM_QUANTITY", "数量") do |q|
#     options[:quantity] = q
#   end
#
#   opts.on("-h", "--help", "ヘルプ表示") do
#     puts opts
#     exit
#   end
# end.parse!

# 必須項目のチェック
# [:quantity].each do |param|
#   if options[param].nil?
#     puts "Error: #{param} is required."
#     exit 1
#   end
# end
# -----------------------------------

invoice_creator = InvoiceCreator.new

# リクエストボディの構築
params = {
  department_id:,
  billing_date:,
  due_date:,
  sales_date:,
  items: [
    {
      name: item_name,
      price: item_price,
      quantity: options[:quantity],
      excise: 'ten_percent'
    }
  ],
  config: {
    consumption_tax_display_type:
  }
}

# 請求書の作成
invoice = invoice_creator.create_invoice(params)

# PDFのダウンロード
invoice_creator.download_pdf(invoice['pdf_url'], download_path)
invoice_creator.update_to_before_billing(invoice['id'])

この雛形をカスタマイズしたスクリプトが以下のようになります。

partner_a.rb
require 'yaml'
require 'dotenv/load'
require 'optparse'
require 'date'
require_relative '../invoice_creator'

department_id = 'xxxxxxxxxx'
billing_date = Date.today.strftime('%Y-%m-%d')
due_date = Date.new(Date.today.year, Date.today.month, -1).strftime('%Y-%m-%d')
sales_date = Date.new(Date.today.last_month, Time.now.month, -1).strftime('%Y-%m-%d')
item_name = 'システム開発費用'
item_price = 10000 # 時間単価
download_path = '/path/to/download.pdf'
consumption_tax_display_type = 'internal'

options = {}
OptionParser.new do |opts|
  opts.banner = "Usage: sample.rb [options]"

  opts.on("-q", "--quantity ITEM_QUANTITY", "数量") do |q|
    options[:quantity] = q
  end

  opts.on("-h", "--help", "ヘルプ表示") do
    puts opts
    exit
  end
end.parse!

[:quantity].each do |param|
  if options[param].nil?
    puts "Error: #{param} is required."
    exit 1
  end
end

invoice_creator = InvoiceCreator.new

params = {
  department_id:,
  billing_date:,
  due_date:,
  sales_date:,
  items: [
    {
      name: item_name,
      price: item_price,
      quantity: options[:quantity],
      excise: 'ten_percent'
    }
  ],
  config: {
    consumption_tax_display_type:
  }
}

invoice = invoice_creator.create_invoice(params)

invoice_creator.download_pdf(invoice['pdf_url'], download_path)
invoice_creator.update_to_before_billing(invoice['id'])
partner_b.rb
require 'yaml'
require 'dotenv/load'
require 'optparse'
require 'date'
require_relative '../invoice_creator'

department_id = 'xxxxxxxxxx'
billing_date = Date.today.strftime('%Y-%m-%d')
due_date = Date.new(Date.today.year, Date.today.month, -1).strftime('%Y-%m-%d')
sales_date = Date.new(Date.today.last_month, Time.now.month, -1).strftime('%Y-%m-%d')
item_name = 'システム開発費用'
item_price = 1000000 # 月単価
download_path = '/path/to/download.pdf'
consumption_tax_display_type = 'internal'

invoice_creator = InvoiceCreator.new

params = {
  department_id:,
  billing_date:,
  due_date:,
  sales_date:,
  items: [
    {
      name: item_name,
      price: item_price,
      quantity: options[:quantity],
      excise: 'ten_percent'
    }
  ],
  config: {
    consumption_tax_display_type:
  }
}

invoice = invoice_creator.create_invoice(params)

invoice_creator.download_pdf(invoice['pdf_url'], download_path)
invoice_creator.update_to_before_billing(invoice['id'])

エイリアス設定

しかしこれでもまだ足りません。
このスクリプトを実行するには毎回わざわざこのディレクトリに移動するか、このディレクトリを指定して実行しないといけませんね。

エンジニアたるもの、このちょっとした手間も見過ごしてはなりません。

仕上げにエイリアスを設定してどのコマンドからもすぐに実行できるようにしましょう。

.zshrc
alias invoice_a='cd /path/to/repository && bundle exec ruby patterns/partner_a.rb'
alias invoice_b='cd /path/to/repository && bundle exec ruby patterns/partner_b.rb'
# 取引先Aへの請求書を作成する場合
invoice_a

# 取引先Bへの請求書を作成する場合(40時間稼働した場合)
invoice_b -q 40

というわけで、これで本当に1コマンドで請求書の発行ができるようになりました。

皆さんも年末年始の忙しい時期に取引先に迷惑を掛けないよう、請求書はさっさと発行するようにしましょうね!

Discussion