freeeアプリを作ってみた

# {RECEIPT}ROLLERとは
{RECEIPT}ROLLER は経費精算処理でもっとも無駄と思われるレシート領収書を入力する手間を省くことができるサービスです。　先日freeeアプリをリリースしましたので、どのように作ったのかここで公開します。

# freeeアプリ
freeeは100万を超える会社が使うクラウド会計サービスです。freeeアプリを作成して公開するとfreeeを利用するユーザーに対してサービスを利用していただけるようになります。

詳細はこちらを参照ください。

https://app.secure.freee.co.jp/developers

## どんなアプリであるべきか

過去にアプリアワード2020というイベントがあったようで、そこに記載されている審査ポイントをベースに「いいアプリ」を設計してみます。

## 審査ポイント
審査ポイントをまず整理

>　仕事現場の問題を的確にとらえ、本質的な課題解決(マジ価値)につながっているか

他社さん引用ですが、1ヵ月に50分ほど経費精算に時間を使ているそうです。その50分を5分以下にするサービスのマジ価値は相当なものだと思います。

https://www.concur.co.jp/expense-management

>　スモールビジネスの現場の助けになり、仕事現場を進化させているか

そのセーブした45分でさらに仕事をしてほしいとは思いませんが、その45分をリラックスできる時間にあてるだけでも生産性は向上すると思っています。

>「楽しさ」「使いやすさ」を感じる「Hack」になっているか

### 楽しさ
レシート領収書を手にした瞬間に写真をとりたくなる楽しさ

### 使いやすさ
写真を撮るだけの簡単なサービス

### Hack
本来ユーザーが情報を受け取るLINEオフィシャルアカウントを送るものに変えてみた。

# アプリの機能概要

まずは手書きでラフに、流れを書いてみました。

![MicrosoftTeams-image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/788025/cb6f608c-afc9-81a2-18a2-c8b75031eee3.png)


ステップ1. LINEから写真をオフィシャルアカウントに対して送付、LINE APIを介して写真を格納。
ステップ2. 写真の内容を確認。 
ステップ3. 写真の内容が確認できたらfreeeのAPIを介してfreeeファイルボックスに格納。

# freee APIを使う前に、まずはトークン取得

トークンを取得してAPIを利用するフローを整理。

![MicrosoftTeams-image (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/788025/cf05125e-c712-8f9c-e79b-6f87c509f65f.png)


1. freeeの許可画面へ遷移
1. 許可後に許可コードと一緒に指定URLへ
1. 許可コード+clientId+clientSecretを使ってトークンをfreeeにリクエストする
1. 戻ってきたトークンを格納する。
1. 格納したトークンを使ってAPIコールをする。
1. コールしたAPIからレスポンスを受け取る。

参照: https://developer.freee.co.jp/startguide/

## 1. freeeの許可画面へ遷移
freeeユーザーがアプリに対してアクセスを許可する必要があります。許可画面（下記）で「許可する」をクリックすると次のページでトークンが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/788025/ab4d23f3-af73-8b11-0393-5eabcfc67d76.png)

## 2. 許可後に許可コードと一緒に指定URLへ

{指定した戻りurl}?code={code} で戻ってきますので、{code}を読込ます。

## 3,4. 許可コード+clientId+clientSecretを使ってトークンをfreeeにリクエスト、戻ってきたトークンを格納

実際にコールしている部分だけ抜粋して下記のように処理しています。 許可コード+clientId+clientSecretをfreeeに渡して、tokenを受け取っています。

```csharp
public async Task<string> FirstTokenAsync(string code)
{

   using (var request = new HttpRequestMessage(new HttpMethod("POST"), "https://accounts.secure.freee.co.jp/public_api/token"))
   {
      var contentList = new List<string>();
      contentList.Add("grant_type=authorization_code");
      contentList.Add("client_id=" + _clientId);
      contentList.Add("client_secret=" + _clientSecret);
      contentList.Add("code=" + code);
      contentList.Add("redirect_uri=" + _redirectUri);
      request.Content = new StringContent(string.Join("&", contentList));
      request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded");

      var response = await _httpClient.SendAsync(request);

      var responseString = await response.Content.ReadAsStringAsync();
      var token = JsonSerializer.Deserialize<FreeeAuthTokenResponseModel>(responseString);

      // Save token
      var id = Guid.NewGuid().ToString();
      _db.FreeeAppCodes.Add(new FreeeCodeModel()
      {
         Code = token.access_token,
         Expire = DateTime.Now.AddHours(6),
         Id = id,
         OwnerId = "",
         RefreshToken = token.refresh_token,
         Token = token.access_token
      });

      await _db.SaveChangesAsync();

      return id;
   }
}

private class FreeeAuthTokenResponseModel
{
   public string access_token { get; set; }
   public string token_type { get; set; }
   public int expires_in { get; set; }
   public string refresh_token { get; set; }
   public string scope { get; set; }
}
```

ステップ5,6は実際にAPIをコールする部分なので次のセクションで説明していきます。

## 実際の遷移をまとめてみる。

実際のルートは二つ想定。

- ルートA アプリマーケットからアプリの詳細を見て、こちら側のアプリインストールページに遷移する。
- ルーツB 自社サイトからアプリ許諾ページに遷移する。

ルートAの場合はそもそもログインしていない、またはアカウントすら持っていないパターンがあるので、まずはアクセスコードと一緒に遷移してきたらログインしているかどうか確認。ここでログインしてない＋アカウントもってない（新規登録必要）というときは一旦遷移を切って新規登録フローへ。すでにアカウントもっているけどログインしてない場合はログインの際にもアクセスコードを引きずりまわしてログイン後こちら側のアプリぺ時に来た時にアクセスコードからトークンを取得して格納。

ルートBの場合はアプリをまだインストールしてないケースが多いと思うので、その場合はこちらのアプリインストールページへ遷移して、そこからルートAと同じパスを通す。

というのをまとめたのがこちらの手書き。

![MicrosoftTeams-image (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/788025/d45d0f88-a46d-d966-a5f0-8c53a8322351.png)


# freee APIの利用

さて・・やっとfreee APIの利用に移ります。

## エンドポイント

freee APIのエンドポイントは下記です

```
https://api.freee.co.jp/
```

## 事業所の取得（テスト）

先ほど取得したトークンを利用して、まずは事業所を取得してみます。　freeeではアカウントに対して複数の事業所を持つことができるので、どの事業所のファイルボックスに格納するのか決める必要があります。

APIドキュメントは下記を参照ください。
https://developer.freee.co.jp/docs/accounting/reference#/Companies/get_companies

Curlリクエスト

```
curl -X GET "https://api.freee.co.jp/api/1/companies" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "X-Api-Version: 2020-06-15"

```

レスポンス

```json
{
  "companies": [
    {
      "id": {id},
      "name": null,
      "name_kana": null,
      "display_name": "株式会社ABC",
      "role": "admin"
    },
    {
      "id": {id},
      "name": null,
      "name_kana": null,
      "display_name": "開発用テスト事業所",
      "role": "admin"
    }
  ]
}
```

## 事業所の取得

実際のコードは下記のように実装しました。

```csharp
public async Task<FreeeCompaniesResponseModel> GetCompaniesAsync(string userId)
{

   var accessToken = await GetExistingTokenOrRefreshAsync(userId);
   var companies = new FreeeCompaniesResponseModel();

   using (var request = new HttpRequestMessage(new HttpMethod("GET"), "https://api.freee.co.jp/api/1/companies"))
   {
      request.Headers.TryAddWithoutValidation("accept", "application/json");
      request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + accessToken);
      request.Headers.TryAddWithoutValidation("X-Api-Version", "2020-06-15");

      var response = await _httpClient.SendAsync(request);

      var responseString = await response.Content.ReadAsStringAsync();
      companies = JsonSerializer.Deserialize<FreeeCompaniesResponseModel>(responseString);
   }

   return companies;
}

public class FreeeCompaniesResponseModel
{
   public List<FreeeCompanyModel> companies { get; set; }
}

public class FreeeCompanyModel
{
   public int id { get; set; }
   public string name { get; set; }
   public string name_kana { get; set; }
   public string display_name { get; set; }
   public string role { get; set; }
}
```

## 経費項目の取得（テスト）

次に経費項目を取得してみます。

下記がCurlリクエスト

```
curl -X GET "https://api.freee.co.jp/api/1/expense_application_line_templates?company_id=861066" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "X-Api-Version: 2020-06-15"
```

下記がレスポンス

```json
{
  "status_code": 401,
  "errors": [
    {
      "type": "status",
      "messages": [
        "アクセス権限がありません。",
        "company_admin",
        "api/v1/expense_application_line_templates",
        "index",
        "このAPIにアクセスしたい場合、事業所の管理者にご確認ください。"
      ],
      "codes": [
        "user_do_not_have_permission"
      ]
    }
  ]
}
```

あれ・・権限の問題発生。プランの問題かな、

そもそもプランが対象外のようで

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/788025/a423a369-01c3-717f-9957-14d88f7ac537.png)

法人プランでベーシックが必要のようです。
改めて事業所をプロフェッショナルプランで作りなして読んでみます。

下記がCurlリクエスト

```
curl -X GET "https://api.freee.co.jp/api/1/expense_application_line_templates?company_id=2682227" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "X-Api-Version: 2020-06-15"
```

下記がレスポンス

```json

{
  "expense_application_line_templates": []
}

```

まだ、経費項目なにも作ってないので空の配列が返ってきています。
ということで、次に経費項目を作ってみます。

下記がCurlリクエスト

```
curl -X POST "https://api.freee.co.jp/api/1/expense_application_line_templates" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "Content-Type: application/json" 
-H "X-Api-Version: 2020-06-15" 
-d "{\"company_id\":2682227,\"name\":\"交通費\",\"account_item_id\":1,\"item_id\":1,\"tax_code\":1,\"description\":\"電車、バス、飛行機などの交通費\",\"line_description\":\"移動区間\"}"
```

下記がレスポンス

```json

{
  "status_code": 400,
  "errors": [
    {
      "type": "status",
      "messages": [
        "不正なリクエストです。"
      ]
    },
    {
      "type": "validation",
      "messages": [
        "税区分「1」は利用できません。",
        "品目「1」が見つかりません。"
      ]
    }
  ]
}
```

税区分と品目を適当に入れてしまったため・・NGのようです。税区分と品目を確認してみます。

## 税区分の取得

税区分を取得してみます。

下記がCurlリクエスト

```
curl -X GET "https://api.freee.co.jp/api/1/taxes/codes" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "X-Api-Version: 2020-06-15"
```

下記がレスポンス

```json

{
  "taxes": [
    {
      "code": 2,
      "name": "non_taxable",
      "name_ja": "対象外"
    }

    // 省略

    {
      "code": 182,
      "name": "taxable_reduced_8",
      "name_ja": "課税8%（軽）"
    }
  ]
}

```

## 品目の取得

次は品目を取得してみます。

下記がCurlリクエスト

```
curl -X GET "https://api.freee.co.jp/api/1/items?company_id=2682227" 
-H "accept: application/json" 
-H "Authorization: Bearer {token}" 
-H "X-Api-Version: 2020-06-15"
```

下記がレスポンス

```json

{
  "items": [
    {
      "id": 179700397,
      "company_id": {companyId},
      "name": "イラストデザイン",
      "shortcut1": null,
      "shortcut2": null
    },
    
    // 省略

    {
      "id": 179700407,
      "company_id": {companyId},
      "name": "高速道路料金",
      "shortcut1": null,
      "shortcut2": null
    }
  ]
}
```

# ファイルボックスにファイルをアップロード

LINEとのやりとり部分は割愛するとして、LINEから取得したレシートデータをファイルボックスにあげる仕組みは下記のように書きました。


```csharp
if (!string.IsNullOrEmpty(user.FreeeToken))
{

   // Get file
   var image = GetImage(originalLog.ImageLocation);

   var myWebClient = new WebClient();
   byte[] imageArray = myWebClient.DownloadData(image.BlobUrl);

   // You want to make sure you have valid token
   var token = await _freeHandlers.GetExistingTokenOrRefreshAsync(user.Id);

   using (var httpClient = new HttpClient())
   {
      using (var request = new HttpRequestMessage(new HttpMethod("POST"), "https://api.freee.co.jp/api/1/receipts"))
      {
         request.Headers.TryAddWithoutValidation("accept", "application/json");
         request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
         request.Headers.TryAddWithoutValidation("X-Api-Version", "2020-06-15");

         var multipartContent = new MultipartFormDataContent();
         multipartContent.Add(new StringContent(user.FreeeCompanyId), "company_id");
         multipartContent.Add(new ByteArrayContent(imageArray), "receipt", originalLog.ImageLocation + ".jpg");
         request.Content = multipartContent;

         var response = await httpClient.SendAsync(request);
      }
   }
}
```

# 申請

急に粒度が荒くなりますが、
あとは環境を整え申請ボタンを押して待つのみです。