chapter02

freee会計と連携したプログラムの実例のご紹介

目次

    前の章では、OAuth2.0の概要と、データ連携を行う際のシステム構成パターンについて説明しました。今回は、その知識を実践に移す具体例として、会計freeeのAPIを使用して損益計算書(PL)と貸借対照表(BS)のデータを取得し、DWHに連携するプログラムをご紹介します。

    ユースケース:会計freeeからDWHへのデータ連携

    今回のユースケースでは、会計freeeの損益計算書(PL)と貸借対照表(BS)のデータをDWHに連携する方法を説明します。

    具体的には、以下の手順でデータを取得し、CSVファイルとして保存します。これは、DWHへの取り込みの前段階として、データを扱いやすい形式に変換するステップです。

    1. freee APIから認証トークンを取得
    2. 取得したトークンを使用してPLとBSのデータを取得
    3. 取得したデータをCSVファイルとして保存

    この方法を使えば、取得したCSVファイルを任意のDWHシステムに簡単にインポートできます。

    freee会計のAPIの特徴

    freee会計のAPIもOAuth2.0の認証形式を採用しており、セキュアな認証と柔軟なアクセス制御を実現しています。ただし、freee会計 APIには留意すべき特徴があります。

    その特徴とはリフレッシュトークンの扱いです。新しいアクセストークンを取得すると、使用したリフレッシュトークンが無効になり、新しいリフレッシュトークンが発行されます。これはSalesforceの認証には無い仕様であり、プログラムの実装ではこの仕様に対応する必要があります。

    この特徴に対応するために、以下の手順が必要になります:

    1. アクセストークン取得時に、新しいリフレッシュトークンも同時に取得
    2. 取得した新しいリフレッシュトークンを保存
    3. 次回のアクセス時には、更新されたリフレッシュトークンを使用

    この方法により、継続的かつ安全にfreee APIにアクセスすることができます。

    実装例:Node.jsを使用したfreee APIとの連携

    それでは、実際にNode.jsを使用してfreee APIから損益計算書(PL)と貸借対照表(BS)のデータを取得し、CSVファイルとして保存するプログラムを見ていきましょう。

    Step 1. 必要な認証情報の取得
    こちらについては多くの記事で紹介されております。以下の記事等をご参照ください。

    Step 2. 認証情報の保存
    実際の運用ではよりセキュアな箇所に保存すべきですが、今回はサンプルとして、'dotenv' を使用しますので、.envファイルに認証情報を保存してください。

    .env

    FREEE_TOKEN_URL=https://accounts.secure.freee.co.jp/public_api/token
    FREEE_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob
    FREEE_CLIENT_ID=<<ここにClient IDを保存してください>>
    FREEE_CLIENT_SECRET=<<ここにClient Secretを保存してください>>
    FREEE_REFRESH_TOKEN=<<ここにRefresh Tokenを保存してください>>
    freee-client.js
    // FreeeClient.js
    const fs = require('fs');
    const request = require('request-promise-native');
    const _ = require('lodash');
    require('dotenv').config();
    
    /**
     * Freee APIと対話するためのFreeeClientクラス
     * このクラスは財務レポートの取得と認証管理のためのメソッドを提供します
     */
    class FreeeClient {
        constructor() {
            this.access_token = null;
            this.refresh_token = null;
            this.base_url = 'https://api.freee.co.jp';
        }
    
        /**
         * 会計年度の損益計算書を取得します
         * @param {number} fiscal_year - データを取得する会計年度
         * @returns {Promise<Array>} - 損益データの配列
         */
        async pl_year(fiscal_year) {
            return this._pl_bs_year(fiscal_year, 'pl');
        }
    
        /**
         * 会計年度の詳細な損益計算書を取得します
         * @param {number} fiscal_year - データを取得する会計年度
         * @returns {Promise<Array>} - 詳細な損益データの配列
         */
        async pl_year_detail(fiscal_year) {
            return this._pl_bs_year(fiscal_year, 'pl', 'partner');
        }
    
        /**
         * 会計年度の貸借対照表を取得します
         * @param {number} fiscal_year - データを取得する会計年度
         * @returns {Promise<Array>} - 貸借対照表データの配列
         */
        async bs_year(fiscal_year) {
            return this._pl_bs_year(fiscal_year, 'bs');
        }
    
        /**
         * 会計年度の詳細な貸借対照表を取得します
         * @param {number} fiscal_year - データを取得する会計年度
         * @returns {Promise<Array>} - 詳細な貸借対照表データの配列
         */
        async bs_year_detail(fiscal_year) {
            return this._pl_bs_year(fiscal_year, 'bs', 'partner');
        }
    
        /**
         * 会計年度の損益計算書または貸借対照表データを取得する内部メソッド
         * @param {number} fiscal_year - データを取得する会計年度
         * @param {string} report_type - レポートの種類('pl'または'bs')
         * @param {string} [breakdown_display_type] - オプションの内訳表示タイプ
         * @returns {Promise<Array>} - 財務データの配列
         */
        async _pl_bs_year(fiscal_year, report_type, breakdown_display_type) {
            const months = _.range(1, 24); // 1から23までの配列を生成
            const all = [];
    
            for (const month of months) {
                const currentYear = month <= 12 ? fiscal_year : fiscal_year + 1;
                const currentMonth = month <= 12 ? month : month - 12;
                const data = await this._pl_bs_month(currentYear, currentMonth, report_type, breakdown_display_type);
                all.push(...data);
            }
    
            return all;
        }
    
        /**
         * 特定の月の損益計算書または貸借対照表データを取得する内部メソッド
         * @param {number} fiscal_year - 会計年度
         * @param {number} fiscal_month - 会計月
         * @param {string} report_type - レポートの種類('pl'または'bs')
         * @param {string} [breakdown_display_type] - オプションの内訳表示タイプ
         * @returns {Promise<Array>} - 指定された月の財務データの配列
         */
        async _pl_bs_month(fiscal_year, fiscal_month, report_type, breakdown_display_type) {
            const optionString = breakdown_display_type ? `&breakdown_display_type=${breakdown_display_type}` : '';
            const endpoint = report_type === 'pl' ? 'trial_pl' : 'trial_bs';
    
            await this.retrieveAccessTokenByRefresh();
            const res = await this.apiRequestGet(`/api/1/reports/${endpoint}?company_id=${process.env.FREEE_PRAZTO_CODE}&fiscal_year=${fiscal_year}&start_month=${fiscal_month}&end_month=${fiscal_month}${optionString}`);
    
            return this.processFinancialData(res, fiscal_year, fiscal_month, report_type, breakdown_display_type);
        }
    
        /**
         * 財務データを処理してフォーマットします
         * @param {Object} res - APIレスポンス
         * @param {number} fiscal_year - 会計年度
         * @param {number} fiscal_month - 会計月
         * @param {string} report_type - レポートの種類('pl'または'bs')
         * @param {string} [breakdown_display_type] - オプションの内訳表示タイプ
         * @returns {Array} - 処理された財務データ
         */
        processFinancialData(res, fiscal_year, fiscal_month, report_type, breakdown_display_type) {
            const data = res[`trial_${report_type}`];
    
            if (!data) {
                return [];
            }
    
            return _.flatMap(data.balances, e => {
                const baseEntry = {
                    ...e,
                    account_month: `${fiscal_year}-${_.padStart(fiscal_month.toString(), 2, '0')}-01`
                };
    
                if (breakdown_display_type) {
                    return this.flattenPartnerData(baseEntry);
                } else {
                    return [baseEntry];
                }
            });
        }
    
        /**
         * freee会計APIからの財務データを取引先ごとに平坦化します
         * 
         * freee会計APIでは、財務データの'partners'プロパティに取引先情報がネストされて返却されます。
         * このメソッドは、そのネストされたデータ構造を解除し、各取引先のデータを個別の行として
         * 平坦化します。これにより、データの分析や処理が容易になります。
         * 
         * 取引先データが存在しない場合は、元のエントリにnullの取引先情報を設定して返します。
         * 取引先データが存在する場合は、各取引先に対して新しいエントリを作成し、
         * 元のエントリの情報と取引先の情報を組み合わせて返します。
         *
         * @param {Object} entry - APIから返却された財務エントリ
         * @returns {Array} - 平坦化されたデータの配列
         */
        flattenPartnerData(entry) {
            if (_.isEmpty(entry.partners)) {
                return [{
                    ...entry,
                    partner_id: null,
                    partner_name: null,
                    comment: ''
                }];
            } else {
                return _.map(entry.partners, partner => ({
                    ..._.omit(entry, 'partners'),
                    partner_id: partner.id,
                    partner_name: partner.name,
                    opening_balance: partner.opening_balance,
                    debit_amount: partner.debit_amount,
                    credit_amount: partner.credit_amount,
                    closing_balance: partner.closing_balance,
                    composition_ratio: partner.composition_ratio
                }));
            }
        }
    
        /**
         * リフレッシュトークンを使用して新しいアクセストークンを取得します
         * @returns {Promise<Object>} - access_tokenとrefresh_tokenを含むオブジェクト
         */
        async retrieveAccessTokenByRefresh() {
            if (this.refresh_token) {
                return;
            }
    
            try {
                const res = await request(this.requestOptionRefreshToken());
                this.writeEnv(res.refresh_token);
                this.access_token = res.access_token;
                this.refresh_token = res.refresh_token;
                return {access_token: res.access_token, refresh_token: res.refresh_token};
            } catch (error) {
                console.error('アクセストークンの取得中にエラーが発生しました:', error);
                throw error;
            }
        }
    
        /**
         * 認可コードを使用して新しいアクセストークンを取得します
         * @param {string} code - 認可コード
         * @returns {Promise<Object>} - access_tokenとrefresh_tokenを含むオブジェクト
         */
        async retrieveAccessTokenByCode(code) {
            if (this.refresh_token) {
                return;
            }
    
            try {
                const res = await request(this.requestOptionAuthorizationCode(code));
                this.writeEnv(res.refresh_token);
                this.access_token = res.access_token;
                this.refresh_token = res.refresh_token;
                return {access_token: res.access_token, refresh_token: res.refresh_token};
            } catch (error) {
                console.error('アクセストークンの取得中にエラーが発生しました:', error);
                throw error;
            }
        }
    
        /**
         * Freee HR APIにGETリクエストを送信します
         * @param {string} url - APIエンドポイント
         * @param {Object} [params] - オプションのクエリパラメータ
         * @returns {Promise<Object>} - APIレスポンス
         */
        async apiRequestGetHr(url, params) {
            try {
                const options = {
                    method: 'GET',
                    url: `${this.base_url}/hr${url}`,
                    qs: {...params, access_token: this.access_token},
                    json: true
                };
                return await request(options);
            } catch (error) {
                console.error('HR APIリクエスト中にエラーが発生しました:', error);
                throw error;
            }
        }
    
        /**
         * Freee APIにGETリクエストを送信します
         * @param {string} url - APIエンドポイント
         * @param {Object} [params] - オプションのクエリパラメータ
         * @returns {Promise<Object>} - APIレスポンス
         */
        async apiRequestGet(url, params) {
            try {
                const options = {
                    method: 'GET',
                    url: `${this.base_url}${url}`,
                    qs: {...params, access_token: this.access_token},
                    json: true
                };
                return await request(options);
            } catch (error) {
                console.error('APIリクエスト中にエラーが発生しました:', error);
                throw error;
            }
        }
    
        /**
         * 認可コード用のリクエストオプションを生成します
         * @param {string} code - 認可コード
         * @returns {Object} - リクエストオプション
         */
        requestOptionAuthorizationCode(code) {
            return {
                method: 'POST',
                url: process.env.FREEE_TOKEN_URL,
                headers: { 'cache-control': 'no-cache', 'Content-Type': 'application/json' },
                form: {
                    grant_type: "authorization_code",
                    redirect_uri: process.env.FREEE_REDIRECT_URI,
                    client_id: process.env.FREEE_CLIENT_ID,
                    client_secret: process.env.FREEE_CLIENT_SECRET,
                    code: code
                },
                json: true
            };
        }
    
        /**
         * リフレッシュトークン用のリクエストオプションを生成します
         * @returns {Object} - リクエストオプション
         */
        requestOptionRefreshToken() {
            return {
                method: 'POST',
                url: process.env.FREEE_TOKEN_URL,
                headers: { 'cache-control': 'no-cache', 'Content-Type': 'application/x-www-form-urlencoded' },
                form: {
                    grant_type: "refresh_token",
                    redirect_uri: process.env.FREEE_REDIRECT_URI,
                    client_id: process.env.FREEE_CLIENT_ID,
                    client_secret: process.env.FREEE_CLIENT_SECRET,
                    refresh_token: process.env.FREEE_REFRESH_TOKEN
                },
                json: true
            };
        }
    
        /**
         * リフレッシュトークンを.envファイルに書き込みます
         * @param {string} refreshToken - 新しいリフレッシュトークン
         */
        writeEnv(refreshToken) {
            const envVars = _(process.env)
                .keys()
                .filter(k => _.startsWith(k, 'ACCOUNT_') || _.startsWith(k, 'BOARD_') || _.startsWith(k, 'FREEE_') || _.startsWith(k, 'SF_'))
                .without('FREEE_REFRESH_TOKEN')
                .map(k => `${k}=${process.env[k]}`)
                .join('\n');
    
            const newEnvContent = `${envVars}\nFREEE_REFRESH_TOKEN=${refreshToken}\n`;
            fs.writeFileSync('./.env', newEnvContent);
        }
    }
    
    module.exports = FreeeClient;

    損益計算書(PL)の取得処理(freee-data-export-pl.js)

    // freee-data-export-pl.js
    const fs = require('fs').promises;
    const _ = require('lodash');
    const { parse } = require('json2csv');
    const FreeeClient = require('./lib/freee-client');
    
    /**
     * Freeeから損益計算書の詳細データを取得し、直接CSVとして保存する
     * @param {number} year - 取得する会計年度
     * @returns {Promise<void>}
     */
    const exportFreePLDetail = async (year) => {
        const freeeClient = new FreeeClient();
        const outputPath = `./data/freee_pl_detail_${year}.csv`;
    
        try {
            console.log(`${year}年度のデータ取得開始`);
            const plData = await freeeClient.pl_year_detail(year);
            
            if (_.isEmpty(plData)) {
                console.warn('データが空です。処理を中断します。');
                return;
            }
    
            const csv = parse(plData);
            await fs.writeFile(outputPath, csv);
            
            console.log(`CSVファイル保存完了: ${outputPath}`);
        } catch (error) {
            console.error('エラーが発生しました:', error);
            throw error;
        }
    };
    
    // メイン処理の実行
    (async () => {
        try {
            const year = 2023;
            await exportFreePLDetail(year);
            console.log('処理完了');
        } catch (error) {
            console.error('プログラム実行エラー:', error);
            process.exit(1);
        }
    })();

    貸借対照表(BS)の取得処理(freee-bs-data-export-bs.js)

    // freee-bs-data-export-bs.js
    const fs = require('fs').promises;
    const _ = require('lodash');
    const { parse } = require('json2csv');
    const FreeeClient = require('./lib/freee-client');
    
    /**
     * Freeeから貸借対照表の詳細データを取得し、直接CSVとして保存する
     * @param {number} year - 取得する会計年度
     * @returns {Promise<void>}
     */
    const exportFreeBSDetail = async (year) => {
        const freeeClient = new FreeeClient();
        const outputPath = `./data/freee_bs_detail_${year}.csv`;
    
        try {
            console.log(`${year}年度のBS取得開始`);
            const bsData = await freeeClient.bs_year_detail(year);
            
            if (_.isEmpty(bsData)) {
                console.warn('データが空です。処理を中断します。');
                return;
            }
    
            const csv = parse(bsData);
            await fs.writeFile(outputPath, csv);
            
            console.log(`CSVファイル保存完了: ${outputPath}`);
        } catch (error) {
            console.error('エラーが発生しました:', error);
            throw error;
        }
    };
    
    // メイン処理の実行
    (async () => {
        try {
            const year = 2023;
            await exportFreeBSDetail(year);
            console.log('処理完了');
        } catch (error) {
            console.error('プログラム実行エラー:', error);
            process.exit(1);
        }
    })();

    このコードは以下の手順で動作します:

    1. リフレッシュトークンを使用してアクセストークンを取得
    2. 新しいリフレッシュトークンを.envに保存(通常の運用はこのようにはなりません)
    3. 取得したアクセストークンを使用してPLとBSのデータを取得
    4. 取得したデータをCSVファイルとして保存

    注意点として、このコードを実行する前に、CLIENT_ID、CLIENT_SECRET、COMPANY_IDを適切な値に設定する必要があります。また、初回のリフレッシュトークンは別途取得し、ファイルに保存しておく必要があります。

    このプログラムを定期的に実行することで、会計freeeのデータを自動的に取得し、CSVファイルとして保存できます。実際の運用では取得したデータをDWHに保存することで、他のビジネスデータと組み合わせた分析が可能になります。

    まとめと次章について

    本章では、freee会計APIを活用して損益計算書(PL)と貸借対照表(BS)のデータを取得し、CSVファイルとして保存するプログラムの実装例を紹介しました。この方法により、会計データを定期的にデータウェアハウス(DWH)に連携し、より深度のある経営分析が可能となります。

    freee APIの特性、特にリフレッシュトークンの適切な管理に注意を払うことで、専用のETL(抽出・変換・ロード)サービスがなくても、継続的なデータ連携を実現できます。さらに、この実装例は他のOAuth2.0を採用しているAPIにも応用可能な汎用性を持っています。

    次の章では、Praztoの実際のシステム連携実績を紹介します。200社以上のSalesforce導入支援を通じて、ETLツールの活用やスクラッチ開発による連携システムの構築など、多様なシステム間連携を実現してきました。システム連携は多くの企業のデジタルトランスフォーメーション(DX)推進において重要な役割を果たしていますが、その実装には専門知識とスキルが必要です。Praztoは豊富な経験と技術力を活かし、お客様のシステム連携プロジェクトの成功をサポートしています。

    【次の章へはこちらから】

    一覧トップへ戻る