MFクラウド請求書APIを使ってコマンド1発で請求書を発行しよう
はじめに
個人事業主の皆さん、先月分の請求書はもう発行しましたか?
僕も毎月マネーフォワードのクラウド請求書をポチポチして副業先への請求書を作ってましたが、こういうルーチンワークはなるべく手間をかけたくないのがエンジニアの性ですよね。
そう思って調べてみると、クラウド請求書のAPIが充実していたので自動化してみることにしました。
リポジトリはこちら
API利用登録
まずはAPIを利用するためにアプリの登録を行います。
クラウド請求書の開発者向けページからアプリ登録に進みます。(MFクラウドのアカウントが必要です)
こちらのチュートリアルに従ってアプリを登録してください。
新規登録画面にてリダイレクトURIの入力が求められますが、localhostと適当なポート・パスでOKです。
(リダイレクト時のパラメータが必要なだけなので、対応するページが存在しなくても問題ありません)
プロジェクト作成
言語は勿論なんでも良いですが、ここではRubyを使うことにします。
適当なディレクトリで以下のGemfileを用意します。
mkdir mf_invoice_creator && cd mf_invoice_creator
touch Gemfile
source 'https://rubygems.org'
ruby '>= 3.2.2'
gem 'httparty' # API通信に使う
gem 'dotenv' # 環境変数設定に使う
bundle install
APIを使った請求書作成の流れは以下の通りです。
見ての通りOAuth2.0による認可フローになります。
- [初回のみ] 認可エンドポイント(
GET https://api.biz.moneyforward.com/authorize
)にアクセスして認可コードを取得 - [初回のみ] 認可コードを使ってトークンエンドポイント(
POST https://api.biz.moneyforward.com/token
)にアクセスし、アクセストークン・リフレッシュトークンを取得 - アクセストークンが期限切れの場合はリフレッシュトークンを使ってトークンエンドポイントにアクセスし、アクセストークン・リフレッシュトークンを取得
- アクセストークンを使って請求書作成APIにアクセスし、請求書を作成
- 請求書をダウンロード
- 請求書ステータスを「下書き」から「未入金」に更新
順番に実装していきます。
初回認証
- 認可エンドポイントにアクセスして認可コードを取得
- 認可コードを使ってトークンエンドポイントにアクセスし、アクセストークン・リフレッシュトークンを取得
を行うクラスを作成します。
環境変数はそれぞれOAuth2.0に使用するパラメータなので、適宜.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文字
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
クラスは基本的にこのクラスからのみ呼ばれることになります。
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
メソッドでステータスの更新もできるようにします。
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管理から除外するのも忘れずに。
.
├── 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
.env
.DS_Store
tokens.json
patterns/*
!patterns/
!patterns/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'])
この雛形をカスタマイズしたスクリプトが以下のようになります。
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'])
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'])
エイリアス設定
しかしこれでもまだ足りません。
このスクリプトを実行するには毎回わざわざこのディレクトリに移動するか、このディレクトリを指定して実行しないといけませんね。
エンジニアたるもの、このちょっとした手間も見過ごしてはなりません。
仕上げにエイリアスを設定してどのコマンドからもすぐに実行できるようにしましょう。
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