この投稿は、Qiita夏祭り『「会計」「勤怠」をハックしよう!freee API のTips募集』への投稿です。
freee APIを使ったSlackアプリを作るまでの話を記載します。
インタビュー
さて、Qiita夏祭りへの参加を決めたもののさーどうしたらいいものか。あれこれ考えましたがなかなかこれというものが出てこず。そんな中閃いたのが現在の勤め先の社長がfreeeを使っているとうこと。その社長にインタビューしてみました。
社長経歴
昨年起業し、現在はWebサービスを立ち上げて、社長兼エンジニア件デザイナー件経理を務める一人社長。
わたくしもバックエンドエンジニアとして週末を利用して開発に参加させていただいております。
それではインタビューです。思い立ったのが夏祭り終了間際だったので、Google Meetでのインタビューとなりました。
ー 経理周りで困っていることはありますか?
「在庫の記帳がめんどくさいです。今はスプレッドシートで在庫管理していて、売れたら自分で更新しています。売上をfreeeに入力するとき、部門とタグなどを都度入力するのがめんどくさいなーと思います。」
ー 振込はどうしていますか?
「振込はめんどくさいですね。請求書見ながら、住信SBIのwebページから振り込んでいます。freeeのページから未決済取引を振り込みたいですね。月末の振り込み忘れてたらどうしようと不安に思う時があります。」
(ベーシックプランにするとこの辺は解決するのかも参考)
ー その他freeeを使っていての困りごとはありますか?
「一度freeeに入るぞ、記帳するぞとfreeeを意識するのはめんどくさいです。なんかきたから記帳するかーとシームレスにできるとありがたい。頭を記帳モードに切り替えないといけないのがめんどくさい。片手間でできるようにしたいです」
「あとは、すぐに認証が切れて毎回ログインするのがめんどくさい。よく認証切れますね。」
([ページ遷移しなければ2時間で認証が切れる](https://support.freee.co.jp/hc/ja/articles/360000860186-%E8%87%AA%E5%8B%95%E7
%9A%84%E3%81%AB%E3%83%AD%E3%82%B0%E3%82%A2%E3%82%A6%E3%83%88%E3%81%97%E3%81%A6%E3%81%97%E3%81%BE%E3%81%86 )そうです)
以上、社長インタビューでした。
話を聞いていく中で私が注目したのは社長のこの言葉。「freeeを意識するのがめんどくさい。freeeを意識せずシームレスに記帳したい。ログインすぐ切れる。」社内のSlackを見る限り、社長はそれなりの頻度でSlackを使っているようなので、APIを使いSlackからfreeeへの切り替えや経理状況の把握ができれば、シームレスな経理業務に一歩近づくはず!と閃きました。(Slackアプリも作ってみたいし)
「freeeの情報をSlackで確認できる」
これをfreee APIを使って実現することにしました。
コンセプトも固まったので、次からは本題の実装に入ります。
実装
シーケンス
- ユーザーがSlackのアプリを開く
- SlackがAPIリクエストを発行する(AWSのAPIGatewayからLambdaにつながる)
- Lambdaがfreee APIを実行する
- freee APIがレスポンスを返す
- Lambdaにて、UIを構築し、Slackにリクエストを送る
- SlackからLambdaへのリクエストに対するレスポンスを返す
- Slackアプリ上にLambdaが発行したUIが反映される
AWS Lambdaを用いてサーバーレスに構築します。
少し特殊だなと思ったのが、Lambda内でSlackに対してアプリ用のリクエストを別途発行する点です。レスポンスとして返すのではなく、新規にSlackに対してリクエストを発行することになります。
シーケンス図ができたので次はSlackアプリのUI構築に取り掛かります。
(Slackアプリを作るにはアプリのインストールや認証などが必要ですが、アプリを作る際に共通の手順ですので本記事では省略します。)
Slack BlocksでのUI構築
SlackではUIを構築するためのツールBlock Kit Builderが提供されており、コードを書くことなくUIを構築することができます。
こんな感じで。↓
このツールをぽちぽちしてめざすUIを構築したのがこちら。↓(ほんと簡単)
画面上部にシームレスにfreeeへ遷移するためのショートカットを置いています。これはfreee API使っておらず、URLリンクとなっています。
その下にボタンを配置し、このボタンをクリックすることにより、後述するAWS Lambdaからfreee APIを呼び出し、取得したデータを画面下部に表示する構成となっております。
さー、これでSlackアプリとしてのUIができました。
次は、このUIをSlack上に表示するための処理を書きます。AWS Lambdaを使ってサーバーレスに実現します。
AWS Lambda
以下、各ファイル・ディレクトリの解説です。コードは長いので本記事の最後に載せます。
lambda_function.py
SlackからのAPIリクエストを受付け、リクエスト内容によって処理を振り分けます。今回はSlackアプリの作成に必要となる認証処理とSlackでアプリを開いた時に発行されるリクエストをさばく処理(今回作るアプリのUIを構築する処理)の2つを実装しています。このファイル内で後述のfreee APIを抽象化したクラスを使用してfreee APIを実行しています。
Slackとfreeeそれぞれに認証トークンが必要で、そのトークンはこちらに記述するようにしています。
views/home.py
SlackアプリのUIを構築する、Viewを担う処理を記述したファイルです。上述のBlock Kit Builderで構築したUI(json)をほぼそのまま記述したものになります。一部、freee APIのレスポンスを渡して、レスポンス内容を表示できるように変更しています。このデータをJSON形式でSlackに送ることにより、制作するアプリのUIをSlack上に表示させることができます。
freee/accounting.py
freee APIの実行を抽象化したクラスです。実際にfreee APIを実行しているのはこのクラスになります。freee APIを実行するコードを書くときにfreee.get('resources', ...)
と簡潔にしたく実装しました。
その他のディレクトリ
その他のディレクトリは Slack API の呼び出しを便利にしてくれるslackclientというモジュールのためのディレクトリです。一度ローカルPCで pip install slackclient
でダウンロードしたものをLambdaにアップしています。
と、以上のような実装でUIやLambada環境を作成し、出来上がったものがこちら。↓
Slackアプリとして動いております ポジティブな反応もいただきました
freee APIから取得したレスポンスのUIへの反映やUIを調整し、最終的にこうなりました。↓
途中、「ボタンは押す必要はなくてアプリのチャンネルを開けばすぐにfreee API実行してUIに表示した方が便利なのでは 」と思い直し、ボタンを削除、APIからのレスポンスのみをシンプルに表示するUIに変更しました。
完成です。
GIF版がこちら。本家freeeさんのようにアプリ選択時にスピナーが回ってくれないのは何かの実装が私のアプリにはないからでしょうか。
夏祭りの感想
以上、freee APIを使ってSlackアプリを制作したQiita夏祭りでした。投稿までやりきれてよかった。
勢いでやってみたはいいもののなかなかアイデアが出てこず「やっぱやめようかなー」と思ったこともありましたが、「実際に使っている人に聞く」この1点で突破できたのかなと思います。
作った後に思いつきましたけど、GAS連携はしたいですね。
後夜祭
ここからは、上記夏祭り本編では書かなかったお話を夏祭り企画にあやかって後夜祭というタイトルのもと書き連ねていきます。
freee APIそのものに対する感想
よかった点
- Swagger UIでAPIの動作を確認できるのはAPIの感触掴める感じでよかった
気になった点
- freee APIページが少し使いにくいと感じた
- SmartHRのAPIやPay.jpのAPIはモダンな感じででAPIを使ってみたくなる雰囲気でした。
- webhook欲しい
- インタビュー中に思いついた機能で、freee上での何かしらの操作を通知してくれたらありがたいと思った
- freeeの操作をSlackに通知することはできるのでwebhookの仕組みそのものは内部的に整っている?
- 2020/08/21:追記
- コピペできるサンプルコードが欲しい
- 最終的にはコードを書く必要があるので、コピペできるものがあるとありがたい
- 途中、pythonからPHPに切り替えてSDKを使ってみようとも思いましたがGitHubのREADMEが複雑ですぐに諦めました(汗)
- 動作確認のために使用したpython3コードを書いたので後述します
- GASでタイマー実行できるので、定期的にfreee APIからデータ取得してslackやメール通知するとリマインダーになって痒いところに手がとどく便利なものが作れそう
- アプリを作った後に思いつくなんてまさに後の祭りですね :zabuton:
- ほんとfeeeとGASとSlack連携で夢広がります。ユーザーにカスタマイズした1日1回のリマインドとか夢感じます。社長も経理上の作業忘れとか不安がってたし。
ふと思ったfreee APIへの要望
- APIの認可機能があると良いかな
- アクセストークンさえあれば全てのデータが見えることは社内セキュリティ的にAPI導入の障害になるのでは
- アクセス可能なリソースやR/Wを設定できるといいのかなー
- 2020/08/21追記:
- freeeアプリ管理ページから設定できるようです
- 会計freeeのホームにもある「コメント」に関する一覧も取得したいがコメントそのものを取得するAPIがない?
/comments
的なAPI。- コメントすなわちTODOの発生と思われるので、コメント時点でslack通知できると担当者がすぐに気づけて良さそう。でもメール通知でいいのか。
-
GET /api/1/wallet_txns
が総数を返して欲しい。deals
はmeta
として返してくれている。
- クラウドの次はノーコードの時代が来ると思っているのでノーコード的な何かがあるといいのかも
コピペしてつかえるpythonコード
Swagger UIとしてfreee APIの仕様は公開されており、そちらのページからAPIを実行することもできます。
しかし、実際にfreee APIを使用する過程で「最終的にコードを書く必要があるからコピペできるコードがあるとありがたいな」と感じました。
というわけで、以下コピペしてつかえるpython3のコードです。
コード内の COMPANY_ID
と ACCESS_TOKEN
はご利用の環境の値を指定してください。
import pprint
import json
import urllib.parse, urllib.request
# NEED: アクセス先事業所の値への変更
# SEE: https://developer.freee.co.jp/getting-started
COMPANY_ID = 事業所IDに書き換えてください
ACCESS_TOKEN = 発行されたアクセストークンに書き換えてください
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + ACCESS_TOKEN,
'X-Api-Version': '2020-06-15',
}
query = {
'company_id': COMPANY_ID,
'status': 'unsettled',
'type': 'expense',
'limit': 100,
}
resources = 'deals'
url = 'https://api.freee.co.jp/api/1/{resources}?{query_string}'.format(
resources='deals',
query_string=urllib.parse.urlencode(query),)
request = urllib.request.Request(
method='GET',
url=url,
headers=headers,)
try:
response = urllib.request.urlopen(request)
content = json.loads(response.read().decode('utf8'))
response = urllib.request.urlopen(request, timeout=10)
content = json.loads(response.read().decode())
pprint.pprint(content)
except urllib.error.HTTPError as e:
pprint.pprint(e.code)
pprint.pprint(json.loads(e.read().decode()))
今回はAWS Lambdaで実行するので urllib
を使用していますが requests
を使うとURLリクエスト周りのコードが簡潔になると思います。
以上、わたくしのQiita夏祭りでした :summer_festival:
最後に、Lambdaにデプロイしたコードで本記事の締めとさせていただきます。
import json
import pprint
import datetime
import views.home
from freee.accounting import API as Freee
import slack
def lambda_handler(event, context):
# NEED: Slackの認証トークン
SLACK_TOKEN = Slackの認証トークン
print(event)
print(event['body'], type(event['body']))
body = event['body']
body = json.loads(event['body'])
# print(body['event'])
# print(body['event']['user'])
# 認証
if 'challenge' in body:
return {
"statusCode": 200,
"body": json.dumps({"challenge": body['challenge']})
}
# ホーム
freee = Freee(
company_id=事業所ID,
# NEED
token=feee APIアクセストークン,)
yesterday = datetime.date.today() + datetime.timedelta(days=1)
overdue_receivable_deals = freee.get(
'deals',
status='unsettled',
type='expense',
end_due_date=yesterday,
limit=100,)
overdue_payable_deals = freee.get(
'deals',
status='unsettled',
type='income',
end_due_date=yesterday,
limit=100,)
next_week = datetime.date.today() + datetime.timedelta(weeks=1)
deals = freee.get(
'deals',
status='unsettled',
type='expense',
end_due_date=next_week,
limit=100,)
pprint.pprint(deals)
wallet_txns = freee.get(
'wallet_txns',
offset=0,
limit=100,)
pprint.pprint(wallet_txns)
client = slack.WebClient(token=SLACK_TOKEN)
client.views_publish(
user_id=body['event']['user'],
view=views.home.view({
'wallet_txns': wallet_txns,
'deals': deals,
'overdue_receivable_deals': overdue_receivable_deals,
'overdue_payable_deals': overdue_payable_deals,}),
)
return {
'statusCode': 200,
'body': json.dumps({'message': 'OK'})
}
def __overdue_deals_view(props = {
'overdue_receivable_deals': None,
'overdue_payable_deals': None,
}):
total_of_overdue_receivable_deals = props['overdue_receivable_deals']['meta']['total_count']
total_of_overdue_payable_deals = props['overdue_payable_deals']['meta']['total_count']
total = total_of_overdue_receivable_deals + total_of_overdue_payable_deals
texts = []
texts.append('*<https://secure.freee.co.jp/#daily-action-notices-js|期日超過した取引 {total}件>*'.format(
total=total
))
texts.append('受け取り期日が過ぎた取引が{total_of_overdue_receivable_deals}件あります'.format(
total_of_overdue_receivable_deals=total_of_overdue_receivable_deals
))
texts.append('支払い期日が過ぎた取引が{total_of_overdue_payable_deals}件あります'.format(
total_of_overdue_payable_deals=total_of_overdue_payable_deals,
))
text = "\n".join(texts)
deals_view = {
"type": "section",
"text": {
"type": "mrkdwn",
"text": text,
}
}
return deals_view
def __upcomming_bills_view(props = {
'deals': None,
}):
total_of_deals = props['deals']['meta']['total_count']
deals_list = []
for deal in props['deals']['deals']:
deals_list.append("{type}\t{due_date}\t¥{due_amount:,}".format(
type=deal['type'],
due_date=deal['due_date'],
due_amount=deal['due_amount'],))
deals_list = "\n".join(deals_list)
deals_text = '*<https://secure.freee.co.jp/#js-contents-deal-summaries|決済期日の近い取引 {total_of_deals}件>*\n'.format(
total_of_deals=total_of_deals,) + deals_list
deals_view = {
"type": "section",
"text": {
"type": "mrkdwn",
"text": deals_text,
}
}
return deals_view
def __wallet_view(props = {
'wallet_txns': None,
'deals': None,
'overdue_receivable_deals': None,
'overdue_payable_deals': None,
}):
number_of_wallet_txns = len(props['wallet_txns']['wallet_txns'])
wallets_text = []
for wallet_txn in props['wallet_txns']['wallet_txns']:
wallets_text.append("{entry_side}\t{date}\t{description}\t¥{amount:,}".format(
entry_side=wallet_txn['entry_side'],
date=wallet_txn['date'],
description=wallet_txn['description'],
amount=wallet_txn['amount'],))
wallets_text = "\n".join(wallets_text)
wallets_view = {
"type": "section",
"text": {
"type": "mrkdwn",
"text": '*<https://secure.freee.co.jp/wallet_txns/stream|未処理の明細 {number_of_wallet_txns}件>*\n'.format(
number_of_wallet_txns=number_of_wallet_txns
) + wallets_text,
}
}
return wallets_view
def view(props = {
'wallet_txns': None,
'deals': None,
}):
view = {
"type": "home",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "<https://secure.freee.co.jp/|ホーム>\t<https://secure.freee.co.jp/deals|取引の一覧>\t<https://secure.freee.co.jp/invoices/|請求書>\t<https://secure.freee.co.jp/expense_applications_v2|経費精算>\t<https://p.secure.freee.co.jp/|人事労務>"
}
},
{"type": "divider"},
__overdue_deals_view(props),
{"type": "divider"},
__upcomming_bills_view(props),
{"type": "divider"},
__wallet_view(props),
]
}
return view
import urllib
import json
class API():
__END_POINT = 'https://api.freee.co.jp/api/1'
def __init__(self, company_id, token):
self.__company_id = company_id
self.__headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
'X-Api-Version': '2020-06-15',
}
def get(self, resources, **query):
url = '{end_point}/{resources}'.format(
end_point=self.__END_POINT,
resources=resources,)
request = urllib.request.Request(
method='GET',
url='{}?company_id={}&{}'.format(
url,
self.__company_id,
urllib.parse.urlencode(query)),
headers=self.__headers,)
try:
response = urllib.request.urlopen(request, timeout=10)
content = json.loads(response.read().decode('utf8'))
except urllib.error.HTTPError as e:
print(e.code)
print(e.read().decode('utf8')))
return content