revolut.lisp 17 KB

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