データ連携

SalesforceとFreeeを連携して、Salesforceの取引先をFreeeに登録してみます。

AppExchangeにFreee for Salesforceと言うものがあるので、実用的にはそちらがオススメですが、Salesforceと外部サービスを連携するにはと言うことで連携してみました。

概要

SalesforceとFreeeを連携するには、以下のように行います。

Freeeの設定

  • Salesforceと連携するためのアプリを登録する

Salesforceの設定

  • FreeeのAPIサイトをリモートサイトとして登録する
  • Freeeの認証を行うサイトを認証プロバイダして登録する
  • Freeeのデータアクセスサイトを指定ログイン情報として登録する
  • 取引先のクイックアクションでFreeeと連携するためにLightning Componentを作成する

設定

(Freee)連携用アプリの作成

手順は、Freeeのサイトに載っていますので、その通りに作成します。

状態は下書き保存のままで大丈夫です。

(Salesforce)リモートサイト設定

Freee連携で使う以下のWeb APIサイトをリモートサイトとして設定します。

Salesforceは、リモートサイトとして設定したサイトにのみ外部アクセスを行うことができます。

(Salesforce)認証プロバイダ設定

Freeeと認証するための設定を行います。通常、Freeeと連携する場合は、Freee側で認可コードを発行し、アクセストークンを取得し、Freee APIにアクセスします。

Salesforceでは、これを認証プロバイダとして登録しておくことにより、必要な時に実行してくれます。

設定のポイントは以下の通りです。

  • プロバイダタイプは、Open ID Connect
  • コンシューマ鍵には、FreeeアプリのClient ID
  • コンシューマの秘密には、FreeeアプリのClient Secret
  • 承認エンドポイントURLには、https://accounts.secure.freee.co.jp/public_api/authorizeを設定
  • トークンエンドポイントURLには、https://accounts.secure.freee.co.jp/public_api/tokenを設定

(Freee)コールバックURLの設定

Salesforceで認証プロバイダの設定を完了すると、コールバックURLが発行されます。これをFreeeアプリのコールバックURLに設定します。

(Salesforce)指定ログイン情報設定

Freee APIのエンドポイントを指定ログイン情報として登録します。これで、SalesforceからFreee APIにアクセスする準備が終わります。

設定のポイントは以下の通りです。

  • URLには、https://api.freee.co.jp/api/1を設定(/api/1を忘れずに)
  • ID種別にユーザ
  • 認証プロトコルにOAuth2.0
  • 認証プロバイダは上で作成した認証プロバイダ
  • 保存時に認証フローを開始をチェック
  • 認証ヘッダーを生成

保存するとFreeeとの認証を開始します。以下のような画面が開きますので、確認して許可するをクリックしてください。これで、認可コードの発行、アクセストークン、リフレッシュトークンの取得が完了します。

ここまで、画面の設定のみで実現できます。

(Salesforce)外部システムの認証設定

私の個人情報の外部システムの認証設定で、指定ログインに対する認証設定を行います。これでSalesforceとFreeeのユーザ情報がリンクされます。

保存時に認証フローを開始をチェックして保存します。Freee画面になったら許可をクリックします。

コードを書きます

ここからは、Salesforceの取引先をFreeeに連携するためのコードを書いていきます。

ちなみに、有償オプションですが、Salesforceには外部データソースという機能があります。これを使うとFreeeの情報をオブジェクトとして扱うことができるようになります。さらには、更新までできると言った優れものです(いつか、余裕ができたらやってみたいです)

Lightning Componentの作成

取引先のクイックアクションとして動作するようにします。ここはLightning Componentで行きます(Lightning Web Componentでは実現できないようです。2019/9/29時点)

pdAccount2Freee.cmp

ポイントは、aura:componentにforce:hasRecordIdを付けることです。これにより取引先のクイックアクションとして実行すると、recordIdと言う属性が自動的に作成され、取引先のIDが入ります。

<aura:component controller="pdAccount2Freee"
    implements="force:lightningQuickActionWithoutHeader,force:hasRecordId">

    <aura:attribute name="account" type="Account" />
    <aura:attribute name="isProgress" type="Boolean" default="false" />
    <aura:attribute name="isDone" type="Boolean" default="false" />
    <aura:attribute name="isError" type="Boolean" default="false" />
    <aura:attribute name="errorMessage" type="String" />

    <aura:handler name="init" value="{!this}" action="{!c.doRegist}" />

    <!-- Display a header with details about the account -->
    <div class="slds-page-header" role="banner">
        <p class="slds-text-heading_label">{!v.account.Name}</p>
        <h1 class="slds-page-header__title slds-m-right_small14
            slds-truncate slds-align-left">Freee取引先連携</h1>
    </div>

    <div class="slds-container">
        <div>
                {!v.recordId}
        </div>
        
        <aura:renderIf isTrue="{!v.isProgress}">
            <lightning:spinner alternativeText="Progress" size="medium" />
        </aura:renderIf>
        <aura:renderIf isTrue="{!v.isDone}">
            完了しました
        </aura:renderIf>
        <aura:renderIf isTrue="{!v.isError}">
            {!v.errorMessage}
        </aura:renderIf>
    </div>
</aura:component>	

pdAccount2FreeeController.js

Apexクラスのaccount2Freeeに取引先IDを渡して実行します。

({
    doRegist : function(component, event, helper) {
        // 処理中フラグを設定する
        component.set("v.isProgress", true);

        // Freeeに取引先を登録する
        const account2Freee = component.get("c.account2Freee");
        account2Freee.setParams({
            "accountId": component.get("v.recordId")
        });

        account2Freee.setCallback(this, (res) => {
            const state = res.getState();
            if (state === "SUCCESS") {
                // 処理完了フラグをセットする
                component.set("v.isDone", true);
            }
            else {
                component.set("v.errorMessage", state);
                component.set("v.isError", true);
            }
            // 処理中フラグを初期化する
            component.set("v.isProgress", false);
        })

        $A.enqueueAction(account2Freee);
    }
})

Apexクラスの作成

次にサーバ(Salesforce)側の処理を書いて行きます。

Salesforceの取引先をFreeeに登録するために、以下のステップで実行していきます。

  • Freeeの事業所情報を取得する(対象となる事業所のcompany_idの取得)
  • Freeeの取引先情報を取得するときにkeywordを指定し、すでに登録されていないかをチェックする
  • Freeeに登録されていなければ取引先を登録する

ここでのポイントは、Freeeにアクセスするためのエンドポイントは、callout:Freee/pathとなる事です。指定ログイン情報として登録したのでcallout、その指定ログイン情報の名称がFreeeです。

あとは、Lightning Componentから呼び出すので、@AuraEnableアノテーションが必要、メソッドはstaticになります。

public with sharing class pdAccount2Freee {
    public virtual class BaseException extends Exception {}
    public class OtherException extends BaseException {}

    /**
    * 取引先の情報尾Freeeの取引先に登録する
     */
    @AuraEnabled
    public static void account2Freee(Id accountId) {
        // 取引先情報を取得する
        Account acc = [select Id,Name,Phone,BillingPostalCode,BillingState,BillingCity,BillingStreet from Account where Id=:accountId];

        // 会社情報を取得する
        Integer companyId = getCompany();

        // Freeeの取引先に存在するかをチェックする
        if(!isExist(companyId, acc)) {
        // 登録されていなければ登録する
            registPartner(companyId, acc);
        }
    }

    /**
    * 会社情報を取得する
     */
    private static Integer getCompany() {    
        // 会社情報を取得する
        Http http = new Http();
        String path = 'callout:Freee/companies';
        HttpRequest req = new HttpRequest();
        req.setEndpoint(path);
        req.setMethod('GET');

        HttpResponse res = http.send(req);
        // 会社情報が返ってきたらIIDと名称を設定する
        if (res.getStatusCode() == 200) {
            Map<String, Object> mapCompany = (Map<String, Object>)JSON.deserializeUntyped(res.getBody());
            List<Object> lstCompany = (List<Object>)mapCompany.get('companies');
            if (lstCompany.size() == 0) {
                throw new OtherException('会社情報が取得できません');
            }
            else {
                return (Integer)((Map<String, Object>)lstCompany[0]).get('id');
            }
        }
        // エラーで返ってきたら例外をスローする
        else {
            throw new OtherException(getApiErrorMessage(res.getBody()));
        }
    }

    /**
    * 同じ取引先が存在するかをチェックする
    */
    private static Boolean isExist(Integer companyId, Account acc) {
        // 取引先名を指定して取引先情報を取得する
        Http http = new Http();
        String path = 'callout:Freee/partners';
        String parameters = 'company_id=' + companyId + '&';
        parameters += 'keyword=' + EncodingUtil.urlEncode(acc.Name, 'UTF-8');
        HttpRequest req = new HttpRequest();
        req.setEndpoint(path + '?' + parameters);
        req.setMethod('GET');

        HttpResponse res = http.send(req);
        if(res.getStatusCode() == 200) {
            // 取引先情報が存在すればtrue、存在しなければfalseを返す
            Map<String, Object> mapBody = (Map<String, Object>)JSON.deserializeUntyped(res.getBody());
            List<Object> lstBody = (List<Object>)mapBody.get('partners');
            if (lstBody.size() == 0) {
                return false;
            }
            else {
                return true;
            }
        }
        // エラーだったら例外をスローする
        else {
            throw new OtherException(getApiErrorMessage(res.getBody()));
        }
    }

    /**
    * 取引先をFreeeに登録する */
    private static void registPartner(Integer companyId, Account acc) {
        Http http = new Http();
        String path = 'callout:Freee/partners';
        String parameters = '{';
        parameters += '"company_id": ' + companyId + ',';
        parameters += '"name": "' + acc.Name + '",';
        parameters += '"long_name": "' + acc.Name + '",';
        parameters += '"default_title": "' + '御中' + '",';
        parameters += '"phone": "' + acc.Phone + '",';
        parameters += '"address_attributes": {';
        parameters += '"zipcode": "' + acc.BillingPostalCode + '",';
        Integer prefCode = getPrefCode(acc.BillingState);
        parameters += '"prefecture_code": ' + ((prefCode == null) ? 'null' : String.valueOf(prefCode)) + ',';
        String[] billingStreet = acc.BillingStreet.split('\r\n');
        if(billingStreet.size() == 0) {
            parameters += '"street_name1": "' + acc.BillingCity + '",';
        }
        else {
            parameters += '"street_name1": "' + acc.BillingCity + billingStreet[0] + '"';
            if (billingStreet.size() >= 2) {
                parameters += ',"street_name2": "' + billingStreet[1] + '"';
            }
        }
        parameters += '}';
        parameters += '}';

        HttpRequest req = new HttpRequest();
        req.setEndpoint(path);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setBody(parameters);

        HttpResponse res = http.send(req);
        if (res.getStatusCode() != 201) {
            throw new OtherException(res.getBody());
        }
    }

    /**
    * エラーメッセージを設定する
     */
    private static String getApiErrorMessage(String body) {
        Map<String, Object> mapError = (Map<String, Object>)JSON.deserializeUntyped(body);
        List<Object> lstError = (List<Object>)(mapError.get('errors'));
        String errorMessage = 'Freee APIでエラーが発生しました\n';
        for(Object mapErrorContent : lstError) {
            List<Object> lstMessage = (List<Object>)((Map<String, Object>)mapErrorContent).get('messages');
            for(Object message : lstMessage) {
                errorMessage += message.toString() + '\n';
            }
        }

        return errorMessage;
    }

    /**
    * 都道府県コード変換
     */
    private static Integer getPrefCode(String prefName) {
        List<String> pref = new List<String> {
            '北海道',
            '青森県',
            '岩手県',
            '宮城県',
            '秋田県',
            '山形県',
            '福島県',
            '茨城県',
            '栃木県',
            '群馬県',
            '埼玉県',
            '千葉県',
            '東京都',
            '神奈川県',
            '新潟県',
            '富山県',
            '石川県',
            '福井県',
            '山梨県',
            '長野県',
            '岐阜県',
            '静岡県',
            '愛知県',
            '三重県',
            '滋賀県',
            '京都府',
            '大阪府',
            '兵庫県',
            '奈良県',
            '和歌山県',
            '鳥取県',
            '島根県',
            '岡山県',
            '広島県',
            '山口県',
            '徳島県',
            '香川県',
            '愛媛県',
            '高知県',
            '福岡県',
            '佐賀県',
            '長崎県',
            '熊本県',
            '大分県',
            '宮崎県',
            '鹿児島県',
            '沖縄県'
        };

        Integer prefCode = null;
        for(Integer i=0 ; i<pref.size() ; i++) {
            if(pref[i] == prefName) {
                prefCode = i;
                break;
            }
        }

        return prefCode;
    }
}

クイックアクションを作成します

取引先にクイックアクションを作成

Salesforceで取引先にクイックアクションを作成します。

クイックアクションをページレイアウトに追加

実行します

取引先を開きます。右上のクイックアクションの中にFreee連携という項目が表示されていますので、実行します。

画面的にはあっさりしていますが、これでSalesforceの取引先情報がFreeeに登録されます。

確認します

Freeeの取引先に登録されているかを確認します。

ApexでJSONをもう少し自由に使えるようになるともっと簡単なんですけどね(文字列としてJSONを正しく構成するのは間違いのもと)

テストコードの準備

Salesforceで本番環境にリリースするためには、Apexクラスのテストコードを書く必要があります。

外部サービスを呼び出すテストコードは実行できないようで、外部サービスの振りをするモックを作成する必要があります。

さらに、今回は指定ログイン情報を使って外部サービスにアクセスしています。この場合、テストコード実行時に指定ログイン情報がないと正しくモックを呼び出してくれないようです(エンドポイントとメソッドがnullになります)

そこで、実際のデータを使ってテストを実施する必要があります。テストコードの宣言を@isTest(SeeAllData=true)とします。

テストコード

@isTest(SeeAllData=true)
private class pdAccount2Freee_test {
    @isTest
    static void test_account2Freee() {

        Test.setMock(HttpCalloutMock.class, new pdAccount2FreeeCalloutMock());

        Account acc = [select Id from Account limit 1];

        pdAccount2Freee.account2Freee(acc.Id);
    }
}

モックコード

global class pdAccount2FreeeCalloutMock implements HttpCalloutMock {
    global HTTPResponse respond(HTTPRequest req) {
        String endPoint = req.getEndpoint();

        HttpResponse res = new HttpResponse();

        if(endPoint == 'callout:Freee/companies') {
            res.setStatusCode(200);
            String body = '{"companies": [{"id": 12345678}]}';
            res.setBody(body);
        }
        else if(endPoint.indexOf('callout:Freee/partners') != (-1)) {
            String method = req.getMethod();
            if(method == 'GET') {
                res.setStatusCode(200);
                String body = '{"partners": []}';
                res.setBody(body);
            }
            else {
                res.setStatusCode(201);
                
            }
        }

        return res;
    }
}

コード

Salesforce DXプロジェクトでのコード一式はGitHubにあります。

https://github.com/kmatae-pitadigi/sf-account2freee