定期的に届くWebサービスのシステム利用料のお知らせメールからfreeeの取引を作成しよう!という挑戦の続きです。なんとか今回で最終回としたいと思います。
前回の記事はこちら。
前回は、取引登録にあたっての雛形となるオブジェクトを取得しました。今回は、この雛形オブジェクトを加工・上書きして新しい取引をPOST(登録)したいと思います。
事前準備
今回もfreee APIへのリクエストにはGAS x freeeAPIライブラリを使用します。こちらのライブラリの事前準備と使用にあたっての注意はこちらの記事をご確認ください。
事前準備を終えた上で、必要になってくるのは、いつもの通りアクセストークンと操作対象の事業所IDです。
事業所IDの取得方法は以下で紹介しています。
雛形オブジェクトをプロパティストアに格納する
function saveTempDeal() {
const accessToken = getService().getAccessToken(); // アクセストークンを取得
const company_id = Number(ScriptProperties.getProperty('COMPANY_ID')); // 事業所IDはプロパティストアに格納
const deal_freeeAPI = freeeAPI.deal(accessToken, company_id); //freeeAPIライブラリのdeal操作オブジェクトを作成
const deal_id = 1288850441; // 取得したい取引のID
const targetDeal = deal_freeeAPI.getDeal(deal_id); // 指定したIDの取引を取得するメソッド
const jsonDeal = JSON.stringify(targetDeal); // 取得したオブジェクトをJSON文字列に変換
PropertiesService.getScriptProperties().setProperty('TEMP_DEAL', jsonDeal);
}
前回までの記事の内容で、雛形となる取引の取引IDがわかります。この取引IDを用いて改めて取引オブジェクトを取得します。
freee上に作成した雛形取引ですが、今回は過去の取引をコピーし未決済の状態にしたものを使用しています。過去の取引そのものだと決済情報までコピーされてしまい、未決済取引をあらたに作るために決済情報を削除しなければならないからです。
しかし、 新たにコピーして作成した雛形未決済取引を、ずっとfreee上に残しておくわけにはいきません。どこかのタイミングで削除して、雛形オブジェクト自体は、GASで保持・呼出できるようにする必要があります。
今回雛形になる取引は、非常にコンパクトなものだったのでプロパティストアに格納することにしました。
しかしプロパティストアは、保持できる容量の上限が決められており
- 9KB / 値 (半角で9216文字)
- プロパティストア全体で500KB
の制限があります。
https://developers.google.com/apps-script/guides/services/quotas
重い取引を雛形にする場合や、同様に雛形オブジェクトを沢山保持しておきたい場合は、プロパティストアではなくスプレッドシートやドライブに書き出しておき、都度呼び出したほうが良いと思います。
またプロパティストアに格納する場合は、JSONオブジェクトでなく、JSON文字列として格納しておきましょう。
const jsonDeal = JSON.stringify(targetDeal); // 取得したオブジェクトをJSON文字列に変換
PropertiesService.getScriptProperties().setProperty('TEMP_DEAL', jsonDeal);
雛形オブジェクトをもとにPOST用のオブジェクトを加工していく
プロパティストアに格納した雛形を再度取り出す時にはJSON.parse()メソッドを用いて、再度JSONオブジェクトにします。
// プロパティストアに格納した雛形JSON文字列を呼び出しJSONオブジェクトに変換
const objTempDeal = JSON.parse(PropertiesService.getScriptProperties().getProperty('TEMP_DEAL'));
さて、GETリクエストで取得した取引オブジェクトのプロパティとPOSTリクエストの時に必要なプロパティは異なります。
ノンプロ研のfreee API講座では、同期講座の明細を例にGETで取得したオブジェクトからPOSTに不要なプロパティを削除して必要な値を上書きしていく演習があります。
これに習って、会計APIリファレンスを確認しながら不要なプロパティを削除していくというのが王道です。
雛形を抽出して、定型の取引をPOSTするのであればこの王道を踏襲するのが最短距離です。もしこの雛形からの不要プロパティの削除やPOST時にエラーになる値がnullやundefinedのプロパティを削除する作業をツール化する場合には、深い沼が待っています。
POSTに必要なプロパティのリストを作成したい
POSTに必要なプロパティのみを残す関数を作ろうとした場合にまず必要になるのは、残すべきプロパティのリストです。
[ 'issue_date',
'type',
'company_id',
'due_date',
'partner_id',
'partner_code',
'ref_number',
'details',
'payments',
'receipt_ids',
'tax_code',
'account_item_id',
'amount',
'item_id',
'section_id',
'tag_ids',
'segment_1_tag_id',
'segment_2_tag_id',
'segment_3_tag_id',
'description',
'vat',
'from_walletable_id',
'from_walletable_type',
'date' ]
上記は取引をPOSTするオブジェクトが持つことのできるプロパティ一覧を配列に格納したものです。このようにPOST用、PUT用と指定可能なプロパティの一覧を手入力で用意しておきます。
続いて取引をGETリクエストした時に取得できるJSONは以下のような構造です。
{
"deal": {
"id": 101,
"company_id": 1,
"issue_date": "2019-12-17",
"due_date": "2019-12-17",
"amount": 5250,
"due_amount": 0,
"type": "expense",
"partner_id": 201,
"partner_code": "code001",
"ref_number": "123-456",
"status": "settled",
"details": [
{
"id": 11,
"account_item_id": 803,
"tax_code": 2,
"item_id": 501,
"section_id": 1,
"tag_ids": [
1,
2,
3
],
"segment_1_tag_id": 1,
"segment_2_tag_id": 1,
"segment_3_tag_id": 1,
"amount": 5250,
"vat": 250,
"description": "備考",
"entry_side": "debit"
}
],
"renews": [
{
"id": 11,
"update_date": "2019-12-17",
"renew_target_id": 12,
"renew_target_type": "detail",
"details": [
{
"id": 1,
"entry_side": "debit",
"account_item_id": 1,
"tax_code": 1,
"item_id": 1,
"section_id": 1,
"tag_ids": [
1
],
"segment_1_tag_id": 1,
"segment_2_tag_id": 1,
"segment_3_tag_id": 1,
"amount": 108000,
"vat": 8000,
"description": "備考"
}
]
}
],
"payments": [
{
"id": 202,
"date": "2019-12-17",
"from_walletable_type": "bank_account",
"from_walletable_id": 103,
"amount": 5250
}
],
"receipts": [
{
"id": 1,
"status": "unconfirmed",
"description": "タクシー利用",
"mime_type": "image/png",
"issue_date": "2019-12-17",
"origin": "public_api",
"created_at": "2019-12-17T18:30:24+09:00",
"user": {
"id": 1,
"email": "contact@example.com",
"display_name": "フリー太郎"
}
}
]
}
}
このJSON文字列はJSON.parse()メソッドでJSONオブジェクトにするとして、このオブジェクトのdealプロパティ内がPOSTに必要なたたき台です。さらにここから不要なプロパティを削除していく関数があると便利です。
オブジェクトから残しておきたいキー一覧に含まれないプロパティを削除する関数
ということで
- GETした雛形オブジェクトのdealプロパティ内の値(=オブジェクト)
- 残しておきたいプロパティの一覧の配列
を引数にして、オブジェクトから残しておきたいキー一覧に含まれないプロパティを削除する関数を作って見ました。
/**
* オブジェクトから残しておきたいキー一覧に含まれないプロパティを削除する関数
* @param {Object} obj - 元となるオブジェクト
* @param {Array} keysKeep - 残したいプロパティの一覧
* @return {Object} obj - 共通しないキーのプロパティを削除したオブジェクト
*/
function deleteDiffProperties(obj, keysKeep) {
const primaryKeys = Object.keys(obj);
const diffKeys = primaryKeys.filter(key => !keysKeep.includes(key));
diffKeys.forEach(difKey => delete obj[difKey]);
primaryKeys.forEach(key => {
if (keysKeep.includes(key)) {
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { deleteDiffProperties(value, keysKeep) }; // nullでないオブジェクト
if (typeof value === 'object' && Array.isArray(value) && value.length > 0) { value.forEach(secondaryObj => deleteDiffProperties(secondaryObj, keysKeep)) }; // 要素が1以上の配列
}
});
return obj
}
この関数の解説だけでもう1記事かけてしまいそうなのですが… freee APIのレスポンスのようにオブジェクトの中にオブジェクト、オブジェクトの中に配列がありその中にオブジェクトといったネストされたオブジェクトに対応するために再帰関数という手法を使っています。
再帰関数に関しては、わたしのGASの師匠であるつじけさんがブログを書いてくださっているので、そちらをご参照ください。
学習と成長のブログ|[プログラミング]再帰関数となかよくなろう 数列と再帰関数
ポイントは各プロパティに格納されている値が
- オブジェクトか
- 配列か
- 文字列・数値など値か
で処理を分岐している点です。値に配列やオブジェクトが格納されている場合は、再帰関数として同じ関数をもう一度実行させています。正直、再帰関数に関しては、私もまだよく理解していないためロジック的に甘い部分があるかと思います。
追記:POSTリクエストの時は問題なかったのですが、PUTリクエスト時にこのdeleteDiffProperties関数を再帰関数にしていると思ったとおりに動作しませんでした。そのためdeleteDiffProperties関数は再帰関数にしないほうが良いようです。
/**
* オブジェクトからテンプレートのキー一覧との共通しないプロパティを削除する関数
* @param {Object} obj - 元となるオブジェクト
* @param {Array} keysKeep - 残したいプロパティの一覧
* @return {Object} obj - 共通しないキーのプロパティを削除したオブジェクト
*/
function deleteDiffProperties(obj, keysKeep) {
const keys = Object.keys(obj);
// テンプレートのキー一覧との共通しないキーの一覧を配列で取得
const diffKeys = keys.filter(key => !keysKeep.includes(key));
// 共通しないキーのプロパティを削除
diffKeys.forEach(difKey => delete obj[difKey]);
return obj;
}
オブジェクトから値がnullやundefined、空のプロパティを削除する関数
続いてfreee APIにPOSTするために必要なのが、オブジェクトから値がnullやundefined、空のプロパティを削除する関数です。freee APIへのPOSTやPUTの際に、登録可能なプロパティであっても値がnullやundefinedなプロパティがあるとエラーが出ます。
これもネストされたオブジェクトに対応するために再帰関数で書いてみました。
/**
* オブジェクトから値がnullやundefined、空のプロパティを削除する関数
* @param {Object} obj - 元となるオブジェクト
* @return {Object} 値がnullや空のプロパティを削除したオブジェクト
*/
function deleteBlankProperties(obj) {
Object.keys(obj).forEach(key => {
const value = obj[key]
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { deleteBlankProperties(value) }; // nullでないオブジェクト
if (typeof value === 'object' && Array.isArray(value) && value.length > 0) { deleteBlankProperties(value) }; // 要素が1以上の配列
if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0) { delete obj[key] }; // nullでない空のオブジェクト
if (typeof value === 'object' && Array.isArray(value) && value.length === 0) { delete obj[key] }; // 要素が空の配列
if (value === null) { delete obj[key] };
if (value === undefined) { delete obj[key] };
if (value === '') { delete obj[key] };
});
return obj;
}
分岐が増えていますね。
- nullでないオブジェクト
- 要素1以上の配列
- 空のオブジェクト
- 空の配列
- null
- undefined
- 空文字列
を判定して値を持っているオブジェクトと配列は再帰関数でループしています。
オブジェクトや配列がネストされたオブジェクトからキー一覧を取得する関数
再帰関数祭りということで、もう1つ便利な関数を作りました。オブジェクトや配列がネストされたオブジェクトからキー一覧を取得する関数です。
オブジェクトのプロパティ一覧を取得するのはObject.keys()メソッドですが、これは子階層以下のオブジェクトには当然適用されません。ということで複数階層のオブジェクト => 再帰関数です。
/**
* オブジェクトの複数階層にわたったユニークなキーの一覧を取得するメソッド
* @param {Object} obj - 元となるオブジェクト
* @return {Array} uniqueKeys - ユニークなキーの一覧
*/
function getAllUniqueKeys(obj) {
const primaryKeys = Object.keys(obj);
const secondaryKeys = primaryKeys.map(key => {
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return getAllUniqueKeys(value) }; // nullでないオブジェクト
if (typeof value === 'object' && Array.isArray(value) && typeof value[0] !== 'object') { return undefined } // 要素にオブジェクトを格納していない配列
// 要素が1以上の配列
if (typeof value === 'object' && Array.isArray(value) && value.length > 0) {
for (const secondaryObj of value) {
return getAllUniqueKeys(secondaryObj);
}
return key;
};
});
const combinedKeys = primaryKeys.concat(secondaryKeys).flat();
const uniqueKeys = Array.from(new Set(combinedKeys)).sort().filter(key => key !== undefined);
// const uniqueKeys = Array.from(new Set(combinedKeys)).filter(key => key !== undefined); // ソートしない場合
return uniqueKeys;
}
これを使えばPOSTに必要なプロパティの一覧をベタ打ちしなくてもよくなります。
function demoGetAllUniqueKeys() {
const json = '{"issue_date": "2019-12-17", "type": "income", "company_id": 1, "due_date": "2019-12-17", "partner_id": 1, "partner_code": "code001", "ref_number": "1", "details": [{"tax_code": 1, "account_item_id": 1, "amount": 1, "item_id": 1, "section_id": 1, "tag_ids": [1], "segment_1_tag_id": 1, "segment_2_tag_id": 1, "segment_3_tag_id": 1, "description": "備考", "vat": 800}], "payments": [{"amount": 5250, "from_walletable_id": 1, "from_walletable_type": "bank_account", "date": "2019-12-17"}], "receipt_ids": [1]}';
const obj = JSON.parse(json);
const uniqueKeys = getAllUniqueKeys(obj);
console.log(uniqueKeys);
}
いよいよ完成間近
さあ役者は揃いました!
第2回までに用意したメインの実行関数は以下のようなコードでした。
function postDealFromMail_02() {
/* 検索クエリを指定して対象メールを絞り込み */
const query = 'subject:(【XXサービス】決済完了メール(自動配信)) newer_than:1m';
const lastThreadMessages = new GmailSearch(query).lastThreadMessages;
/* 各メールから必要な情報を抜粋したオブジェクトを格納した配列を作成 */
const aryMailInfoObjs = lastThreadMessages.map(message => {
const formatDate = Utilities.formatDate(message.getDate(), 'JST', 'yyyy-MM-dd'); // 日付をyyyy-MM-dd型の文字列に
const recipient = message.getTo(); // 受信アドレスが複数の場合はカンマ区切り
const plainBody = message.getPlainBody();
const prefix = '\r\n■ ご利用金額 :';
const suffix = '円\r\n\r\n\r\n';
const fees = RegularExp.extractStrings(plainBody, prefix, suffix); // 独自クラスRegularExpを使用
const amount = fees[0].replace(',', ''); // 金額内のカンマをブランクに置き換える
return { mailDate: formatDate, mailRecipient: recipient, amount: amount };
});
console.log(aryMailInfoObjs);
}
aryMailInfoObjs配列には、受信日(yyy-MM-dd文字列)・受信アドレス・金額を記録したオブジェクトが格納されています。この配列の各要素に対して雛形オブジェクトのプロパティを上書きしてPOSTして行きましょう。
受信アドレスと登録部門を紐つける
最後の仕上げ前に、もうひとつ必要なのが、受信アドレスから付与したい部門IDに変換する作業です。こうしたある値をIDに変換する時には列挙型(Enum)風のオブジェクトが便利です。
function demoObjEnum() {
const objEnumSection = {
'honbu@freeelover.com': 123456, // 本部の部門ID
'osaka@freeelover.com': 456789, // 大阪の部門ID
'tokyo@freeelover.com': 789123, // 東京の部門ID
'fukuoka@freeelover.com': 123789, // 福岡の部門ID
};
const mail = 'tokyo@freeelover.com';
console.log(objEnumSection[mail]); // 789123
console.log(convertValue2Key(objEnumSection, 456789)); // osaka@freeelover.com
}
/**
* あるオブジェクトの値に対応するプロパティ名を返す関数
* @param {Object} objEnum - 列挙型(Enum)風のオブジェクト
* @param {any} targetValue - 変換したい値
* @return {string} 値に対応したプロパティ名
*/
function convertValue2Key(obj, targetValue) {
for (const [key, value] of Object.entries(obj)) {
if (value === targetValue) { return key };
}
return null;
}
これで、メールアドレスから部門IDの変換がスムーズにできますし、また値からプロパティ名を返す関数(上記の例だとconvertValue2Key関数)を作れば相互変換も可能になります。
GAS x freeeAPIライブラリを利用した取引のPOST
個別のPOST用のオブジェクトが作成に必要なデータが出揃ったら、オブジェクトに代入した上でGAS x freeeAPIライブラリを使ってPOSTします。
今回はfreee API dealクラスのpostDeal(payload)メソッドを利用します。このメソッドはJSONオブジェクトから取引を登録するメソッドになります。
引数のpayloadにPOST用に整えたJSONオブジェクトを渡すとfreeeに取引が登録されます。
freeeAPI.deal(accessToken, company_id).postDeal(payload)
ということで最終形
function postDealFromMail() {
/* 検索クエリを指定して対象メールを絞り込み */
const query = 'subject:(【XXサービス】決済完了メール(自動配信)) newer_than:1m';
const lastThreadMessages = new GmailSearch(query).lastThreadMessages; // 独自クラスGmailSearch 連載第1回参照
/* 各メールから必要な情報を抜粋したオブジェクトを格納した配列を作成 */
const aryMailInfoObjs = lastThreadMessages.map(message => {
const formatDate = Utilities.formatDate(message.getDate(), 'JST', 'yyyy-MM-dd'); // 日付をyyyy-MM-dd型の文字列に
const recipient = message.getTo(); // 受信アドレスが複数の場合はカンマ区切り
const plainBody = message.getPlainBody();
const prefix = '\r\n■ ご利用金額 :';
const suffix = '円\r\n\r\n\r\n';
const fees = extractStrings(plainBody, prefix, suffix); // 連載第2回参照
const amount = fees[0].replace(',', ''); // 金額内のカンマをブランクに置き換える
return { mailDate: formatDate, mailRecipient: recipient, amount: amount };
});
/* プロパティストアに格納した雛形JSON文字列を呼び出しJSONオブジェクトに変換 */
const objTempDeal = JSON.parse(PropertiesService.getScriptProperties().getProperty('TEMP_DEAL'));
const accessToken = getService().getAccessToken(); // アクセストークンを取得
const company_id = Number(ScriptProperties.getProperty('COMPANY_ID')); // 事業所IDはプロパティストアに格納
const deal_freeeAPI = freeeAPI.deal(accessToken, company_id); //freeeAPIライブラリのdeal操作オブジェクトを作成
/* 2022-08-11 追記 deleteDiffProperties関数を再帰関数でなく1階層のみに有効な関数に変更 */
// const keysKeep = getAllUniqueKeys(deal_freeeAPI.objPost); // deal操作オブジェクト内のPOST用のサンプルオブジェクトからプロパティ一覧を取得
// const objPostTemp = deleteDiffProperties(objTempDeal, keysKeep); // POSTに不要なプロパティを削除
// const objPost = deleteBlankProperties(objPostTemp); // null, undefined, 空の値などのプロパティを削除
const keysKeep = Object.keys(deal_freeeAPI.objPut); // POST用に残しておくべき第1階層のキー配列
const objPost = deleteDiffProperties(objTempDeal, keysKeep); // POSTに不要なプロパティを削除
const keysKeepDetails = Object.keys(deal_freeeAPI.objPut.details[0]); // POST用に残しておくべき第2階層(details以下)のキー配列
objPost.details.forEach(detail => deleteDiffProperties(detail, keysKeepDetails)); // POSTに不要なプロパティを削除
deleteBlankProperties(objPost); // null, undefined, 空の値などのプロパティを削除
/* メールアドレスと部門IDを変換するための列挙型(Enum)風オブジェクト */
const objEnumSection = {
'honbu@freeelover.com': 123456, // 本部の部門ID
'osaka@freeelover.com': 456789, // 大阪の部門ID
'tokyo@freeelover.com': 789123, // 東京の部門ID
'fukuoka@freeelover.com': 123789, // 福岡の部門ID
};
aryMailInfoObjs.forEach(objInfo => {
objPost.issue_date = objInfo.mailDate; // 受信日を発生日に
objPost.amount = objInfo.amount; // メールから取得した金額を上書き
objPost.details[0].amount = objInfo.amount; // 明細行の金額にも上書き
objPost.details[0].section_id = objEnumSection[objInfo.mailRecipient]; // 受信アドレスを部門IDに変換して上書き
objPost.details[0].tag_ids = [12345678]; // *GAS登録などのメモタグを作成し付与しておくと後で検索しやすい
deal_freeeAPI.postDeal(objPost); // GAS x freeeAPIライブラリを利用してPOST
})
}
あとはこの関数をトリガーで定期実行すれば完璧ですね!(エラー処理を忘れずに…)
シリーズ目次
- GAS x freeeAPIライブラリのトリセツ「定期的に届くメールからfreeeの取引を作成しよう!」その1 – 対象メールの絞り込み
- GAS x freeeAPIライブラリのトリセツ「定期的に届くメールからfreeeの取引を作成しよう!」その2 – POST用の情報を抜粋する
- GAS x freeeAPIライブラリのトリセツ「定期的に届くメールからfreeeの取引を作成しよう!」その3 – 雛形オブジェクトをGETしよう
- GAS x freeeAPIライブラリのトリセツ「定期的に届くメールからfreeeの取引を作成しよう!」その4 – 雛形上書きしてPOSTしよう
Amazon欲しい物リスト公開しています。
開発者のモチベーションアップのためにAmazon欲しい物リストを公開しております。役に立ったよ!という方の感謝の気持ちで何かいただけるのであれば嬉しいです笑