あさってからできる!Slack家計簿!
Google I/Oに来ていますが、ぎりぎり何も始まっていないので
あらかじめ書いておいたゴミみたいな記事をお楽しみいただければと思います。
単刀直入に言うと、僕たちの家はお金の清算がらくです。
僕らの家は、僕と彼女で住んでいます。
しいてあげるなら、うっちーもいますので2匹と1人といえます。
ある人は夫婦で住んでいたり、お子さんもいることだってあるでしょう。
そうなると、二人でのお金管理はしばしば面倒なのです!
そんなあなたに、Slackだけでできる家計簿システムをおススメしましょう!
やりかたは簡単!たったの3ステップ!
1、家庭用のSlackをつくりましょう!
これはスタートであり、もはやゴールです。
Slackに登録できたら、旦那さんや彼女やうっちーを招待しましょう!
こんなものができる見込みです。
家庭用Slackを作ると、家庭内での会話の趣旨をチャンネル別にわけれたり
その他のIntegrationで、買い物リストなどが便利になります。
2、家庭内サーバ(またはインスタンス)にHubotをインストールしたら、使用履歴を蓄えるためのDBも一緒に立ち上げて、特定の言葉に反応して家計簿をつけれるように設定しよう!
2-1、SlackでHubotを仲間にしよう!
Hubot | Slack
https://slack.com/apps/A0F7XDU93-hubot
ここで仲間にしたHubotのアクセストークンも発行されます。後で使います。
2-2、このページのインストールくらいまでを見て、Hubotをインストールしよう!
Hubot のインストールと Hello World
http://qiita.com/suppy193/items/27ffbe932877a1d7e8ee
「Botなんだから再起動しても勝手に立ち上がってほしい!」
「エラーで落ちても復帰してほしい!」
わがままですね。
2-3、自動起動の設定をしましょう!
pm2を使えば、最寄りのバーガーキングに行くよりも早く設定できますよ
hubotをSlackと連携してデーモン化するまでの手順
http://nametake-1009.hatenablog.com/entry/2016/02/09/155105
こちらがわかりやすいです。
pm2 start app.json をしたと思ったら、Slackにオンラインのマークがついていることでしょう。
2-4、言葉に反応しよう or なにかアップロードされたら反応しよう!
言葉に反応しよう!
scripts/ 以下に、適当な名前の.jsファイル、または.coffeeファイルを設置するよ
1 2 3 4 5 |
module.exports = robot => { robot.respond(/.*/, res => { res.send('Nice to meet you.'); }) } |
これで、@ボットの名前 に対してメンションを送れば、
Nice to meet you. と返してくれます。
この
robot.respond(/.*/, の正規表現をいろいろなものに変えると、「○○買ったよ、2460円だったよ」に反応できるわけです。
アップロードされたら反応しよう!
我が家では #receipt チャンネルを作って、そこにレシートがアップロードされたら買ったということにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
const general = require('../configs/general.json'); module.exports = robot => { robot.listen(msg => { if(msg.message && general.receipt_room == msg.room && msg.message.subtype == 'file_share') { msg.message.text.match(/\((.*)\).+:\s*(.*)$/); const image_url = RegExp.$1; const price = parseInt(RegExp.$2); const place = msg.message.file.title; const who = msg.message.user.name; msg.opts = { image_url: image_url, price: price, place: place, who: who }; return; } }, res => { const opts = res.message.opts; res.reply(`登録するよ!\n場所は${ opts.place }で〜\nつかった金額は${ opts.price.toLocaleString() }円ね!`); }); } |
入力は
どこで買ったか、いくらだったかだけ入力!
ちょっと雑だけど、これが一番入力が楽かなとおもって続けています。
レシートはたんなる目視確認用の記録です。
これらの情報を取り出すのは、かっこよくできず match で妥協しました
1 2 3 |
msg.message.text.match(/\((.*)\).+:\s*(.*)$/); const image_url = RegExp.$1; const price = parseInt(RegExp.$2); |
ちなみに configs/general.json はこんな感じ↓↓。
1 2 3 4 |
{ "receipt_room": "C29JCKLGP", "console_room": "C534PF2G5" } |
部屋のIDをまとめてるだけです。
2-5、DBに格納しよう!
DBはこんな感じで。
2-4で取得した情報を格納する程度で運用してます。
3、運用しよう!
はっきり言うと、この記事で言いたかったのはここだけなのです。
「払った金額だけを束ねて、後で見るの?」
そうではありません!
もともと僕と彼女の運用では、共通の銀行口座に毎月10万円を振り込みます。
(じぶん銀行が無料で自動入金(pull)してくれて便利です)
定額自動入金サービス | じぶん銀行
http://www.jibunbank.co.jp/service/money_order/
同じ額入れてる口座なので、月一で集計される金額も
払った金額そのまま、払った人に戻せばおっけー
返金の処理もうまく自動化したかったのですが
PayPal for Slackが日本口座には対応しておらず断念
(何か良さそうなツールがあったらおしえてください)
そして、残ったお金で光熱費や貯金をするという寸法。
結局これで何が変わったの。
二人で飲食、買い物、散髪なんでもかんでも。
どちらか片方が支払って、何の不都合なく清算できます!
あと、スケールできます。
3人や4人でも。
レシートの部分に関するコードのっけます、こんなかんじです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
const mysql = require('mysql'); const moment = require('moment-timezone'); const secret = require('../configs/secret.json'); const general = require('../configs/general.json'); const receiptConfig = require('../configs/receipt-config.json'); const THIS_MONTH_ORDER_EXP = new RegExp(receiptConfig.THIS_MONTH_ORDER.join('|')); const LAST_MONTH_ORDER_EXP = new RegExp(receiptConfig.LAST_MONTH_ORDER.join('|')); const LAST_ORDERED_DETAILS_EXP = new RegExp(receiptConfig.LAST_ORDERED_DETAILS.join('|')); const CLOSING_DATE = 25; const pool = mysql.createPool({ connectionLimit: 10, host: '192.168.0.250', user: 'slack_bot', password: secret['MYSQL_PASSWORD'], database: 'slack' }); let lastOrderedClaim = null; module.exports = robot => { robot.listen((msg) => { // レシート登録のところ if(msg.message && general.receipt_room == msg.room && msg.message.subtype == 'file_share') { msg.message.text.match(/\((.*)\).+:\s*(.*)$/); const image_url = RegExp.$1; const price = parseInt(RegExp.$2); const place = msg.message.file.title; const who = msg.message.user.name; msg.opts = { image_url: image_url, price: price, place: place, who: who }; pool.getConnection((conErr, connection) => { if(conErr) { robot.messageRoom(general.console_room, ````${ conErr }````); } else { connection.query('INSERT INTO payments (who, price, image, place) VALUES (?, ?, ?, ?)', [who, price, image_url, place], (err, rows) => { if (err) { robot.messageRoom(general.console_room, ````${ err }````); } connection.release(); }); } }); return true; } }, (res) => { const opts = res.message.opts; res.reply(`登録するよ!\n場所は${ opts.place }で〜\nつかった金額は${ opts.price.toLocaleString() }円ね!`); }); // 以下レシート表示系 robot.respond(THIS_MONTH_ORDER_EXP, (res) => { if(general.receipt_room === res.envelope.room) { showClaim(0).then(reply => { res.send(`${ reply }`); }); } }); robot.respond(LAST_MONTH_ORDER_EXP, (res) => { if(general.receipt_room === res.envelope.room) { showClaim(1).then(reply => { res.send(`${ reply }`); }); } }); robot.respond(LAST_ORDERED_DETAILS_EXP, (res) => { if(general.receipt_room === res.envelope.room) { if(lastOrderedClaim === null) { res.send('さきに集計してね'); return; } showClaimDetails(lastOrderedClaim).then(reply => { res.send(`${ reply }`); }); } }); }; const showClaim = (subMounth) => { lastOrderedClaim = subMounth; return pullClaim(calcClaimRange(subMounth)).then(({result, start, end}) => { let replyString = `*集計日:${start} 〜 ${end}*\n\n`; result.forEach(v => replyString += `>*${v.who}*: ${v.total.toLocaleString()}円\n`); return replyString; }); }; const showClaimDetails = (subMounth) => { lastOrderedClaim = null; return pullClaimDetails(calcClaimRange(subMounth)).then(({result, start, end}) => { let replyString = `*集計日:${start} 〜 ${end}*\n\n`; result.forEach(v => replyString += `>*${v.who}*: ${v.price.toLocaleString()}円(${v.place})\n`); return replyString; }); }; const calcClaimRange = (subMounth) => { const currentDate = moment().tz('Asia/Tokyo'); const endClone = currentDate.clone(); const aggregationStartDate = CLOSING_DATE + 1; const aggregationStartMonth = (CLOSING_DATE < currentDate.date() ? currentDate.subtract(subMounth, 'month').month() // CLOSING_DATE〜月末 : currentDate.subtract(1 + subMounth, 'month').month()) + 1; const aggregationStartYear = currentDate.year(); const aggregationEndDate = CLOSING_DATE; const aggregationEndMonth = (CLOSING_DATE < currentDate.date() ? endClone.add(1 - subMounth, 'month').month() // CLOSING_DATE〜月末 : endClone.subtract(subMounth, 'month').month()) + 1; const aggregationEndYear = endClone.year(); return { aggregationStartYear: aggregationStartYear, aggregationStartMonth: aggregationStartMonth, aggregationStartDate: aggregationStartDate, aggregationEndYear: aggregationEndYear, aggregationEndMonth: aggregationEndMonth, aggregationEndDate: aggregationEndDate }; }; const pullClaim = ({ aggregationStartYear, aggregationStartMonth, aggregationStartDate, aggregationEndYear, aggregationEndMonth, aggregationEndDate }) => { return new Promise((resolve, reject) => { pool.getConnection((conErr, connection) => { if(conErr) { robot.messageRoom(general.console_room, ````${ conErr }````); reject(); } else { const start = `${aggregationStartYear}-${aggregationStartMonth}-${aggregationStartDate} 00:00:00`; const end = `${aggregationEndYear}-${aggregationEndMonth}-${aggregationEndDate} 23:59:59`; connection.query('SELECT who, sum(price) as total FROM payments WHERE bought_at BETWEEN ? AND ? GROUP BY who', [start, end], (err, rows) => { if (err) { robot.messageRoom(general.console_room, ````${ err }````); reject(); } connection.release(); resolve({result: rows, start: start, end: end}); }); } }); }); }; const pullClaimDetails = ({ aggregationStartYear, aggregationStartMonth, aggregationStartDate, aggregationEndYear, aggregationEndMonth, aggregationEndDate }) => { return new Promise((resolve, reject) => { pool.getConnection((conErr, connection) => { if(conErr) { robot.messageRoom(general.console_room, ````${ conErr }````); reject(); } else { const start = `${aggregationStartYear}-${aggregationStartMonth}-${aggregationStartDate} 00:00:00`; const end = `${aggregationEndYear}-${aggregationEndMonth}-${aggregationEndDate} 23:59:59`; connection.query('SELECT who, place, price FROM payments WHERE bought_at BETWEEN ? AND ?', [start, end], (err, rows) => { if (err) { robot.messageRoom(general.console_room, ````${ err }````); reject(); } connection.release(); resolve({result: rows, start: start, end: end}); }); } }); }); }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
{ "THIS_MONTH_ORDER": [ "今月分は", "今月分は?", "今月は", "今月分は?", "今月分", "こんげつぶん", "今月どう", "今月どう?" ], "LAST_MONTH_ORDER": [ "先月分は", "先月分は?", "先月は", "先月分は?", "先月分", "せんげつぶん", "先月どう", "先月どう?" ], "LAST_ORDERED_DETAILS": [ "うちわけは", "うちわけは?", "内訳は", "内訳は?", "うちわけ", "内訳" ] } |
1 2 3 4 |
{ "receipt_room": "C29JCKLGP", "console_room": "C534PF2G5" } |