revolut.lisp 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.revolut
  3. (:use :cl :chatikbot.common :alexandria))
  4. (in-package :chatikbot.plugins.revolut)
  5. (defparameter +api-domain+ "app.revolut.com")
  6. (defparameter +api-uri+ (format nil "https://~a/api/retail/" +api-domain+))
  7. (defparameter +device-id+ "cb483962-b75c-4e4d-b4da-e5afee9a664c")
  8. (defparameter +user-agent+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0")
  9. (defvar *accounts* nil "alist of account aliases, by id")
  10. (defvar *merchant-expenses*
  11. '(("78c23116-39ff-4ebf-a311-344dbc9d507d" . "expenses:transport:taxi") ; Bolt
  12. ("239ce793-91a7-47ba-8085-9de84c02b1c6" . "expenses:travel:hotel") ; booking.com
  13. ("5a3451cf-dc08-4d94-9969-a4d080b7402c" . "expenses:food:fast-food") ; McDonalds
  14. ))
  15. (defvar *category-expenses*
  16. '(("56bdc570-99c6-420d-a855-d09dc5eaabc3" . "expenses:food:work")))
  17. (defvar *tag-expenses*
  18. '(("groceries" . "expenses:food:groceries")
  19. ("restaurants" . "expenses:food:restaurants")
  20. ("health" . "expenses:life:health")))
  21. (defvar *topup-accounts*
  22. '(("Top-Up by *1508" . "assets:rbcz:czk")))
  23. (defvar *fee-account* "expenses:banking:fee")
  24. (defun is-content (rest-method)
  25. (case rest-method
  26. ((:post :put) t)))
  27. (defun make-cookie-jar (token)
  28. (when token
  29. (cl-cookie:make-cookie-jar
  30. :cookies
  31. (list
  32. (cl-cookie:make-cookie
  33. :name "credentials"
  34. :value (base64:string-to-base64-string
  35. (format nil "~a:~a" (agets token "user" "id") (agets token "accessToken")))
  36. :domain +api-domain+)))))
  37. (defmethod poller-request ((module (eql :revolut)) method &rest params)
  38. (handler-case
  39. (let* ((uri (if (listp method) (car method) method))
  40. (rest-method (if (listp method) (cdr method) :get))
  41. (is-content (is-content rest-method))
  42. (res
  43. (json-request (concatenate 'string +api-uri+ uri)
  44. :headers `((:x-device-id . ,+device-id+)
  45. (:x-browser-application . "WEB_CLIENT"))
  46. :user-agent +user-agent+
  47. :cookie-jar (make-cookie-jar *poller-token*)
  48. :method rest-method
  49. :json-content is-content
  50. (if is-content :content :parameters)
  51. (rest-parameters params t))))
  52. res)
  53. (dex:http-request-failed (e) e)))
  54. (defmethod poller-validate ((module (eql :revolut)) response)
  55. (not (typep response 'dex:http-request-unauthorized)))
  56. (defun signin (phone passcode)
  57. (let ((sign-res (poller-request :revolut '("signin" . :post)
  58. :|phone| phone
  59. :|channel| "APP"
  60. :|password| passcode)))
  61. (when (listp sign-res)
  62. (loop with start = (get-universal-time)
  63. for res = (poller-request :revolut '("token" . :post)
  64. :|phone| phone
  65. :|password| passcode
  66. :|tokenId| (agets sign-res "tokenId"))
  67. when (listp res) do (return res)
  68. unless (= (dex:response-status res) 422) do (return)
  69. when (> (- (get-universal-time) start) 30) do (return)
  70. do (sleep 2)))))
  71. (defun refresh-token (user-id refresh-code)
  72. (let* ((res (poller-request :revolut '("token" . :put)
  73. :|userId| user-id
  74. :|refreshCode| refresh-code)))
  75. (when (listp res) res)))
  76. (defmethod poller-get-token ((module (eql :revolut)) secret)
  77. (let ((user-id (agets *poller-token* "user" "id"))
  78. (refresh-code (agets *poller-token* "refreshCode")))
  79. ;; Try to refresh old token
  80. (when (and user-id refresh-code)
  81. (when-let (token (refresh-token user-id refresh-code))
  82. (return-from poller-get-token
  83. (append token `(("user" . (("id" . ,user-id))))))))
  84. ;; Signin for a new token
  85. (destructuring-bind (phone . passcode) secret
  86. (signin phone passcode))))
  87. (defun get-user ()
  88. (poller-call :revolut "user/current"))
  89. (defun get-user-portfolio ()
  90. (poller-call :revolut "user/current/portfolio"))
  91. (defun get-user-features ()
  92. (poller-call :revolut "user/current/features"))
  93. (defun get-user-wallet ()
  94. (poller-call :revolut "user/current/wallet"))
  95. (defun get-user-money-boxes ()
  96. (poller-call :revolut "user/current/money-boxes"))
  97. (defun get-my-card-all ()
  98. (poller-call :revolut "my-card/all"))
  99. (defun get-currencies (&optional (type "fiat"))
  100. "Type could be 'fiat', 'crypto' or 'commodity'"
  101. (poller-call :revolut "currencies" :|type| type))
  102. (defun get-cashback ()
  103. (poller-call :revolut "cashback"))
  104. (defun get-quote (symbol)
  105. (poller-call :revolut "quote" :|symbol| symbol))
  106. (defun get-transactions-last (&key (count 10) pocket to)
  107. (poller-call :revolut "user/current/transactions/last"
  108. :|count| count
  109. :|to| to ;; timestamp (startedDate) of last received transaction
  110. :|internalPocketId| pocket))
  111. (defun get-transactions-vault (&key id)
  112. (poller-call :revolut "user/current/transactions/vault" :|id| id))
  113. (defun get-transaction (id)
  114. (poller-call :revolut (format nil "transaction/~a" id)))
  115. (defun get-recurring-payments ()
  116. (poller-call :revolut "recurring-payments"))
  117. (defparameter +unix-epoch-difference+
  118. (encode-universal-time 0 0 0 1 1 1970 0))
  119. (defun unix-to-universal-time (unix-time)
  120. (+ unix-time +unix-epoch-difference+))
  121. (defun universal-to-unix-time (universal-time)
  122. (- universal-time +unix-epoch-difference+))
  123. (defun get-date (ms)
  124. (unix-to-universal-time (round ms 1000)))
  125. (defun format-short-date (ms)
  126. (if ms
  127. (multiple-value-bind (sec min hour day month year)
  128. (decode-universal-time (get-date ms))
  129. (declare (ignore sec min hour))
  130. (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
  131. "-"))
  132. (defun get-amount (amount)
  133. (/ amount 100))
  134. (defun format-pocket-account (p)
  135. (when p
  136. (let ((cur (string-downcase (agets p "currency"))))
  137. (concatenate 'string
  138. "assets:revolut:"
  139. (or (agets *accounts* (agets p "id"))
  140. (when (agets p "name") (string-downcase (agets p "name")))
  141. (when (equal "SAVINGS" (agets p "type"))
  142. (concatenate 'string "savings" cur))
  143. cur)))))
  144. (defun format-pocket-balance (p)
  145. (format nil "; balance ~A ~A = ~,2F ~A"
  146. (format-short-date (* 1000 (universal-to-unix-time (get-universal-time))))
  147. (format-pocket-account p)
  148. (get-amount (agets p "balance"))
  149. (agets p "currency")))
  150. (defun format-entries (changes)
  151. (text-chunks (mapcar #'pta-ledger:render (remove nil changes))))
  152. (defun find-pocket (id pockets)
  153. (find id pockets :key (agetter "id") :test #'equal))
  154. (defun get-expense-account (tr)
  155. (or (agets *merchant-expenses* (agets tr "merchant" "merchantId"))
  156. (agets *category-expenses* (agets tr "category"))
  157. (agets *tag-expenses* (agets tr "tag"))
  158. (concatenate 'string "expenses:" (agets tr "tag"))))
  159. (defun params-exchange (tr pockets)
  160. (when (equal "sell" (agets tr "direction"))
  161. (let ((description (agets tr "description"))
  162. (rcv-account (format-pocket-account (find-pocket
  163. (agets tr "counterpart" "account" "id")
  164. pockets)))
  165. (rcv-amount (get-amount (agets tr "counterpart" "amount")))
  166. (rcv-currency (agets tr "counterpart" "currency"))
  167. (snd-account (format-pocket-account (find-pocket
  168. (agets tr "account" "id") pockets)))
  169. (snd-amount (get-amount (agets tr "amount")))
  170. (snd-currency (agets tr "currency")))
  171. (values description nil
  172. rcv-account rcv-amount rcv-currency
  173. snd-account snd-amount snd-currency))))
  174. (defun params-topup (tr pockets)
  175. (let* ((description (agets tr "description"))
  176. (rcv-account (format-pocket-account (find-pocket
  177. (agets tr "account" "id") pockets)))
  178. (rcv-amount (get-amount (agets tr "amount")))
  179. (rcv-currency (agets tr "currency"))
  180. (snd-account (or (agets *topup-accounts* description) "income"))
  181. (snd-amount (* -1 rcv-amount))
  182. (snd-currency rcv-currency))
  183. (values description nil
  184. rcv-account rcv-amount rcv-currency
  185. snd-account snd-amount snd-currency)))
  186. (defun params-transfer (tr pockets)
  187. (when (equal "CURRENT" (agets tr "account" "type"))
  188. (let* ((description (agets tr "description"))
  189. (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
  190. pockets)))
  191. (snd-amount (get-amount (agets tr "amount")))
  192. (snd-currency (agets tr "currency"))
  193. (rcv-account (or (format-pocket-account (find-pocket
  194. (or
  195. (agets tr "recipient" "account" "id")
  196. (agets tr "sender" "account" "id"))
  197. pockets))
  198. (get-expense-account tr)))
  199. (rcv-amount (* -1 snd-amount))
  200. (rcv-currency snd-currency))
  201. (values description nil
  202. rcv-account rcv-amount rcv-currency
  203. snd-account snd-amount snd-currency))))
  204. (defun params-atm (tr pockets)
  205. (let* ((description (agets tr "description"))
  206. (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
  207. pockets)))
  208. (snd-amount (get-amount (agets tr "amount")))
  209. (snd-currency (agets tr "currency"))
  210. (rcv-currency (agets tr "counterpart" "currency"))
  211. (rcv-amount (* -1 (get-amount (agets tr "counterpart" "amount"))))
  212. (rcv-account (format nil "assets:cash:~(~a~)" rcv-currency)))
  213. (values description nil
  214. rcv-account rcv-amount rcv-currency
  215. snd-account snd-amount snd-currency)))
  216. (defun params-fee (tr pockets)
  217. (let* ((description (agets tr "description"))
  218. (rcv-account *fee-account*)
  219. (rcv-amount (* -1 (get-amount (agets tr "amount"))))
  220. (rcv-currency (agets tr "currency"))
  221. (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
  222. pockets)))
  223. (snd-amount (* -1 rcv-amount))
  224. (snd-currency rcv-currency))
  225. (values description nil
  226. rcv-account rcv-amount rcv-currency
  227. snd-account snd-amount snd-currency)))
  228. (defun params-card-payment (tr pockets)
  229. (let* ((merchant-name (agets tr "merchant" "name"))
  230. (tr-description (agets tr "description"))
  231. (description (or merchant-name tr-description))
  232. (comment (unless (equal description tr-description) tr-description))
  233. (rcv-account (get-expense-account tr))
  234. (rcv-amount (get-amount (* -1 (agets tr "counterpart" "amount"))))
  235. (rcv-currency (agets tr "counterpart" "currency"))
  236. (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
  237. pockets)))
  238. (snd-amount (get-amount (agets tr "amount")))
  239. (snd-currency (agets tr "currency")))
  240. (values description comment
  241. rcv-account rcv-amount rcv-currency
  242. snd-account snd-amount snd-currency)))
  243. (defun params-card-refund (tr pockets)
  244. (let* ((description (agets tr "description"))
  245. (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
  246. pockets)))
  247. (snd-amount (get-amount (agets tr "amount")))
  248. (snd-currency (agets tr "currency"))
  249. (rcv-account "income:refund")
  250. (rcv-amount (get-amount (* -1 (agets tr "counterpart" "amount"))))
  251. (rcv-currency (agets tr "counterpart" "currency")))
  252. (values description nil
  253. rcv-account rcv-amount rcv-currency
  254. snd-account snd-amount snd-currency)))
  255. (defun default-account (currency)
  256. (concatenate 'string "assets:revolut:" (string-downcase currency)))
  257. (defun transaction->entry (tr pockets)
  258. (let* ((date (get-date (agets tr "startedDate")))
  259. (type (keyify (agets tr "type")))
  260. (state (keyify (agets tr "state")))
  261. (fee (get-amount (or (agets tr "fee") 0))))
  262. (case state ((:declined :failed :cancelled :reverted :deleted)
  263. (return-from transaction->entry)))
  264. (multiple-value-bind (description comment
  265. rcv-account rcv-amount rcv-currency
  266. snd-account snd-amount snd-currency)
  267. (case type
  268. (:exchange (params-exchange tr pockets))
  269. (:topup (params-topup tr pockets))
  270. (:atm (params-atm tr pockets))
  271. (:transfer (params-transfer tr pockets))
  272. (:fee (params-fee tr pockets))
  273. (:card-payment (params-card-payment tr pockets))
  274. (:card-refund (params-card-refund tr pockets)))
  275. (when snd-amount
  276. (pta-ledger:make-entry
  277. :date date
  278. :description description
  279. :comment comment
  280. :postings (remove nil (list
  281. (pta-ledger:make-posting
  282. :account (or rcv-account (default-account rcv-currency))
  283. :amount (pta-ledger:make-amount
  284. :quantity rcv-amount
  285. :commodity rcv-currency)
  286. :total-price (unless (equal rcv-currency snd-currency)
  287. (pta-ledger:make-amount
  288. :quantity (abs snd-amount)
  289. :commodity snd-currency)))
  290. (unless (zerop fee)
  291. (pta-ledger:make-posting
  292. :account *fee-account*
  293. :amount (pta-ledger:make-amount
  294. :quantity fee :commodity snd-currency)))
  295. (pta-ledger:make-posting
  296. :account (or snd-account (default-account snd-currency))
  297. :amount (pta-ledger:make-amount
  298. :quantity (- snd-amount fee)
  299. :commodity snd-currency)))))))))
  300. (defun handle-auth (login pass)
  301. (handler-case
  302. (progn
  303. (poller-authenticate :revolut (cons login pass))
  304. (handle-balance))
  305. (poller-cant-authenticate ()
  306. (bot-send-message "Чот не смог, пропробуй другие."))))
  307. (defun handle-balance ()
  308. (bot-send-message
  309. (handler-case
  310. (let ((entries (mapcar 'format-pocket-balance (agets (get-user-wallet) "pockets"))))
  311. (if entries (text-chunks entries) "Не нашлось"))
  312. (poller-no-secret () "Нужен логин-пароль. /revolut <phone> <pin>")
  313. (poller-cant-get-token () "Не смог получить данные. Попробуй перелогинься. /revolut <phone> <pin>"))
  314. :parse-mode "markdown"))
  315. (defun prepare-entries (transactions)
  316. (let ((pockets (agets (get-user-wallet) "pockets")))
  317. (delete nil (mapcar (lambda (tr) (transaction->entry tr pockets)) transactions))))
  318. (defun handle-recent (&optional (count 10))
  319. (bot-send-message
  320. (handler-case
  321. (format-entries (prepare-entries (get-transactions-last :count count)))
  322. (poller-no-secret () "Нужен логин-пароль. /revolut <phone> <pin>")
  323. (poller-cant-get-token () "Не смог получить данные. Попробуй перелогинься. /revolut <phone> <pin>"))
  324. :parse-mode "markdown"))
  325. (defun handle-list (enable)
  326. (lists-set-entry :revolut *chat-id* enable)
  327. (bot-send-message (if enable "Рассылаю обновления" "Молчу, пока не спросишь")))
  328. (def-message-cmd-handler handle-cmd-revolut (:revolut :revo)
  329. (let ((a0 (car *args*)))
  330. (cond
  331. ((= 2 (length *args*)) (apply 'handle-auth *args*))
  332. ((equal a0 "on") (handle-list t))
  333. ((equal a0 "off") (handle-list nil))
  334. ((or (null *args*) (equal a0 "bal")) (handle-balance))
  335. (:otherwise (handle-recent (parse-integer a0 :junk-allowed t))))))
  336. (defun process-new (transactions)
  337. (let ((ledger-package (find-package :chatikbot.plugins.ledger)))
  338. (if ledger-package
  339. (let ((new-chat-entry (symbol-function
  340. (intern "LEDGER/NEW-CHAT-ENTRY" ledger-package))))
  341. (dolist (entry transactions)
  342. (funcall new-chat-entry *chat-id* (pta-ledger:clone-entry entry))))
  343. (bot-send-message (format-entries transactions) :parse-mode "markdown"))))
  344. (defcron process-revolut ()
  345. (poller-poll-lists :revolut
  346. (lambda () (prepare-entries (get-transactions-last)))
  347. #'process-new
  348. :key #'pta-ledger:entry-date))