revolut.lisp 18 KB

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