|
@@ -0,0 +1,368 @@
|
|
|
|
|
+(in-package :cl-user)
|
|
|
|
|
+(defpackage chatikbot.plugins.revolut
|
|
|
|
|
+ (:use :cl :chatikbot.common :alexandria))
|
|
|
|
|
+(in-package :chatikbot.plugins.revolut)
|
|
|
|
|
+
|
|
|
|
|
+(defparameter +api-domain+ "app.revolut.com")
|
|
|
|
|
+(defparameter +api-uri+ (format nil "https://~a/api/retail/" +api-domain+))
|
|
|
|
|
+(defparameter +device-id+ "cb483962-b75c-4e4d-b4da-e5afee9a664c")
|
|
|
|
|
+(defparameter +user-agent+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0")
|
|
|
|
|
+
|
|
|
|
|
+(defvar *accounts* nil "alist of account aliases, by id")
|
|
|
|
|
+(defvar *merchant-expenses*
|
|
|
|
|
+ '(("78c23116-39ff-4ebf-a311-344dbc9d507d" . "expenses:transport:taxi") ; Bolt
|
|
|
|
|
+ ("239ce793-91a7-47ba-8085-9de84c02b1c6" . "expenses:travel:hotel") ; booking.com
|
|
|
|
|
+ ("5a3451cf-dc08-4d94-9969-a4d080b7402c" . "expenses:food:fast-food") ; McDonalds
|
|
|
|
|
+ ))
|
|
|
|
|
+(defvar *category-expenses*
|
|
|
|
|
+ '(("56bdc570-99c6-420d-a855-d09dc5eaabc3" . "expenses:food:work")))
|
|
|
|
|
+(defvar *tag-expenses*
|
|
|
|
|
+ '(("groceries" . "expenses:food:groceries")
|
|
|
|
|
+ ("restaurants" . "expenses:food:restaurants")
|
|
|
|
|
+ ("health" . "expenses:life:health")))
|
|
|
|
|
+
|
|
|
|
|
+(defvar *topup-accounts*
|
|
|
|
|
+ '(("Top-Up by *1508" . "assets:rbcz:czk")))
|
|
|
|
|
+
|
|
|
|
|
+(defun is-content (rest-method)
|
|
|
|
|
+ (case rest-method
|
|
|
|
|
+ ((:post :put) t)))
|
|
|
|
|
+
|
|
|
|
|
+(defun make-cookie-jar (token)
|
|
|
|
|
+ (when token
|
|
|
|
|
+ (cl-cookie:make-cookie-jar
|
|
|
|
|
+ :cookies
|
|
|
|
|
+ (list
|
|
|
|
|
+ (cl-cookie:make-cookie
|
|
|
|
|
+ :name "credentials"
|
|
|
|
|
+ :value (base64:string-to-base64-string
|
|
|
|
|
+ (format nil "~a:~a" (agets token "user" "id") (agets token "accessToken")))
|
|
|
|
|
+ :domain +api-domain+)))))
|
|
|
|
|
+
|
|
|
|
|
+(defmethod poller-request ((module (eql :revolut)) method &rest params)
|
|
|
|
|
+ (handler-case
|
|
|
|
|
+ (let* ((uri (if (listp method) (car method) method))
|
|
|
|
|
+ (rest-method (if (listp method) (cdr method) :get))
|
|
|
|
|
+ (is-content (is-content rest-method))
|
|
|
|
|
+ (res
|
|
|
|
|
+ (json-request (concatenate 'string +api-uri+ uri)
|
|
|
|
|
+ :headers `((:x-device-id . ,+device-id+)
|
|
|
|
|
+ (:x-browser-application . "WEB_CLIENT"))
|
|
|
|
|
+ :user-agent +user-agent+
|
|
|
|
|
+ :cookie-jar (make-cookie-jar *poller-token*)
|
|
|
|
|
+ :method rest-method
|
|
|
|
|
+ :json-content is-content
|
|
|
|
|
+ (if is-content :content :parameters)
|
|
|
|
|
+ (rest-parameters params t))))
|
|
|
|
|
+ res)
|
|
|
|
|
+ (dex:http-request-failed (e) e)))
|
|
|
|
|
+
|
|
|
|
|
+(defmethod poller-validate ((module (eql :revolut)) response)
|
|
|
|
|
+ (not (typep response 'dex:http-request-unauthorized)))
|
|
|
|
|
+
|
|
|
|
|
+(defun signin (phone passcode)
|
|
|
|
|
+ (let ((sign-res (poller-request :revolut '("signin" . :post)
|
|
|
|
|
+ :|phone| phone
|
|
|
|
|
+ :|channel| "APP"
|
|
|
|
|
+ :|password| passcode)))
|
|
|
|
|
+ (when (listp sign-res)
|
|
|
|
|
+ (loop with start = (get-universal-time)
|
|
|
|
|
+ for res = (poller-request :revolut '("token" . :post)
|
|
|
|
|
+ :|phone| phone
|
|
|
|
|
+ :|password| passcode
|
|
|
|
|
+ :|tokenId| (agets sign-res "tokenId"))
|
|
|
|
|
+ when (listp res) do (return res)
|
|
|
|
|
+ unless (= (dex:response-status res) 422) do (return)
|
|
|
|
|
+ when (> (- (get-universal-time) start) 600) do (return)
|
|
|
|
|
+ do (sleep 2)))))
|
|
|
|
|
+
|
|
|
|
|
+(defun refresh-token (user-id refresh-code)
|
|
|
|
|
+ (let* ((res (poller-request :revolut '("token" . :put)
|
|
|
|
|
+ :|userId| user-id
|
|
|
|
|
+ :|refreshCode| refresh-code)))
|
|
|
|
|
+ (when (listp res) res)))
|
|
|
|
|
+
|
|
|
|
|
+(defmethod poller-get-token ((module (eql :revolut)) secret)
|
|
|
|
|
+ (let ((user-id (agets *poller-token* "user" "id"))
|
|
|
|
|
+ (refresh-code (agets *poller-token* "refreshCode")))
|
|
|
|
|
+ ;; Try to refresh old token
|
|
|
|
|
+ (when (and user-id refresh-code)
|
|
|
|
|
+ (when-let (token (refresh-token user-id refresh-code))
|
|
|
|
|
+ (return-from poller-get-token
|
|
|
|
|
+ (append token `(("user" . (("id" . ,user-id))))))))
|
|
|
|
|
+ ;; Signin for a new token
|
|
|
|
|
+ (destructuring-bind (phone . passcode) secret
|
|
|
|
|
+ (signin phone passcode))))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-user ()
|
|
|
|
|
+ (poller-call :revolut "user/current"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-user-portfolio ()
|
|
|
|
|
+ (poller-call :revolut "user/current/portfolio"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-user-features ()
|
|
|
|
|
+ (poller-call :revolut "user/current/features"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-user-wallet ()
|
|
|
|
|
+ (poller-call :revolut "user/current/wallet"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-user-money-boxes ()
|
|
|
|
|
+ (poller-call :revolut "user/current/money-boxes"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-my-card-all ()
|
|
|
|
|
+ (poller-call :revolut "my-card/all"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-currencies (&optional (type "fiat"))
|
|
|
|
|
+ "Type could be 'fiat', 'crypto' or 'commodity'"
|
|
|
|
|
+ (poller-call :revolut "currencies" :|type| type))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-cashback ()
|
|
|
|
|
+ (poller-call :revolut "cashback"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-quote (symbol)
|
|
|
|
|
+ (poller-call :revolut "quote" :|symbol| symbol))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-transactions-last (&key (count 10) pocket to)
|
|
|
|
|
+ (poller-call :revolut "user/current/transactions/last"
|
|
|
|
|
+ :|count| count
|
|
|
|
|
+ :|to| to
|
|
|
|
|
+ :|internalPocketId| pocket))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-transactions-vault (&key id)
|
|
|
|
|
+ (poller-call :revolut "user/current/transactions/vault" :|id| id))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-transaction (id)
|
|
|
|
|
+ (poller-call :revolut (format nil "transaction/~a" id)))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-recurring-payments ()
|
|
|
|
|
+ (poller-call :revolut "recurring-payments"))
|
|
|
|
|
+
|
|
|
|
|
+(defparameter +unix-epoch-difference+
|
|
|
|
|
+ (encode-universal-time 0 0 0 1 1 1970 0))
|
|
|
|
|
+
|
|
|
|
|
+(defun unix-to-universal-time (unix-time)
|
|
|
|
|
+ (+ unix-time +unix-epoch-difference+))
|
|
|
|
|
+
|
|
|
|
|
+(defun universal-to-unix-time (universal-time)
|
|
|
|
|
+ (- universal-time +unix-epoch-difference+))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-date (ms)
|
|
|
|
|
+ (unix-to-universal-time (round ms 1000)))
|
|
|
|
|
+
|
|
|
|
|
+(defun format-short-date (ms)
|
|
|
|
|
+ (if ms
|
|
|
|
|
+ (multiple-value-bind (sec min hour day month year)
|
|
|
|
|
+ (decode-universal-time (get-date ms))
|
|
|
|
|
+ (declare (ignore sec min hour))
|
|
|
|
|
+ (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
|
|
|
|
|
+ "-"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-amount (amount)
|
|
|
|
|
+ (/ amount 100))
|
|
|
|
|
+
|
|
|
|
|
+(defun format-pocket-account (p)
|
|
|
|
|
+ (when p
|
|
|
|
|
+ (let ((cur (string-downcase (agets p "currency"))))
|
|
|
|
|
+ (concatenate 'string
|
|
|
|
|
+ "assets:revolut:"
|
|
|
|
|
+ (or (agets *accounts* (agets p "id"))
|
|
|
|
|
+ (when (agets p "name") (string-downcase (agets p "name")))
|
|
|
|
|
+ (when (equal "SAVINGS" (agets p "type"))
|
|
|
|
|
+ (concatenate 'string "savings" cur))
|
|
|
|
|
+ cur)))))
|
|
|
|
|
+
|
|
|
|
|
+(defun format-pocket-balance (p)
|
|
|
|
|
+ (format nil "; balance ~A ~A = ~,2F ~A"
|
|
|
|
|
+ (format-short-date (* 1000 (universal-to-unix-time (get-universal-time))))
|
|
|
|
|
+ (format-pocket-account p)
|
|
|
|
|
+ (get-amount (agets p "balance"))
|
|
|
|
|
+ (agets p "currency")))
|
|
|
|
|
+
|
|
|
|
|
+(defun format-entries (changes)
|
|
|
|
|
+ (text-chunks (mapcar #'pta-ledger:render (remove nil changes))))
|
|
|
|
|
+
|
|
|
|
|
+(defun find-pocket (id pockets)
|
|
|
|
|
+ (find id pockets :key (agetter "id") :test #'equal))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-expense-account (tr)
|
|
|
|
|
+ (or (agets *merchant-expenses* (agets tr "merchant" "merchantId"))
|
|
|
|
|
+ (agets *category-expenses* (agets tr "category"))
|
|
|
|
|
+ (agets *tag-expenses* (agets tr "tag"))
|
|
|
|
|
+ (concatenate 'string "expenses:" (agets tr "tag"))))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-exchange (tr pockets)
|
|
|
|
|
+ (when (equal "sell" (agets tr "direction"))
|
|
|
|
|
+ (let ((description (agets tr "description"))
|
|
|
|
|
+ (rcv-account (format-pocket-account (find-pocket
|
|
|
|
|
+ (agets tr "counterpart" "account" "id")
|
|
|
|
|
+ pockets)))
|
|
|
|
|
+ (rcv-amount (get-amount (agets tr "counterpart" "amount")))
|
|
|
|
|
+ (rcv-currency (agets tr "counterpart" "currency"))
|
|
|
|
|
+ (snd-account (format-pocket-account (find-pocket
|
|
|
|
|
+ (agets tr "account" "id") pockets)))
|
|
|
|
|
+ (snd-amount (get-amount (agets tr "amount")))
|
|
|
|
|
+ (snd-currency (agets tr "currency")))
|
|
|
|
|
+ (values description nil
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency))))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-topup (tr pockets)
|
|
|
|
|
+ (let* ((description (agets tr "description"))
|
|
|
|
|
+ (rcv-account (format-pocket-account (find-pocket
|
|
|
|
|
+ (agets tr "account" "id") pockets)))
|
|
|
|
|
+ (rcv-amount (get-amount (agets tr "amount")))
|
|
|
|
|
+ (rcv-currency (agets tr "currency"))
|
|
|
|
|
+ (snd-account (agets *topup-accounts* description))
|
|
|
|
|
+ (snd-amount (* -1 rcv-amount))
|
|
|
|
|
+ (snd-currency rcv-currency))
|
|
|
|
|
+ (values description nil
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency)))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-transfer (tr pockets)
|
|
|
|
|
+ (when (equal "CURRENT" (agets tr "account" "type"))
|
|
|
|
|
+ (let* ((description (agets tr "description"))
|
|
|
|
|
+ (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
|
|
|
|
|
+ pockets)))
|
|
|
|
|
+ (snd-amount (get-amount (agets tr "amount")))
|
|
|
|
|
+ (snd-currency (agets tr "currency"))
|
|
|
|
|
+ (rcv-account (or (format-pocket-account (find-pocket
|
|
|
|
|
+ (or
|
|
|
|
|
+ (agets tr "recipient" "account" "id")
|
|
|
|
|
+ (agets tr "sender" "account" "id"))
|
|
|
|
|
+ pockets))
|
|
|
|
|
+ (get-expense-account tr)))
|
|
|
|
|
+ (rcv-amount (* -1 snd-amount))
|
|
|
|
|
+ (rcv-currency snd-currency))
|
|
|
|
|
+ (values description nil
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency))))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-atm (tr pockets)
|
|
|
|
|
+ (let* ((description (agets tr "description"))
|
|
|
|
|
+ (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
|
|
|
|
|
+ pockets)))
|
|
|
|
|
+ (snd-amount (get-amount (agets tr "amount")))
|
|
|
|
|
+ (snd-currency (agets tr "currency"))
|
|
|
|
|
+ (rcv-currency (agets tr "counterpart" "currency"))
|
|
|
|
|
+ (rcv-amount (* -1 (get-amount (agets tr "counterpart" "amount"))))
|
|
|
|
|
+ (rcv-account (format nil "assets:cash:~(~a~)" rcv-currency)))
|
|
|
|
|
+ (values description nil
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency)))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-fee (tr pockets)
|
|
|
|
|
+ (let* ((description (agets tr "description"))
|
|
|
|
|
+ (rcv-account "expenses:banking:fee")
|
|
|
|
|
+ (rcv-amount (* -1 (get-amount (agets tr "amount"))))
|
|
|
|
|
+ (rcv-currency (agets tr "currency"))
|
|
|
|
|
+ (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
|
|
|
|
|
+ pockets)))
|
|
|
|
|
+ (snd-amount (* -1 rcv-amount))
|
|
|
|
|
+ (snd-currency rcv-currency))
|
|
|
|
|
+ (values description nil
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency)))
|
|
|
|
|
+
|
|
|
|
|
+(defun params-card-payment (tr pockets)
|
|
|
|
|
+ (let* ((merchant-name (agets tr "merchant" "name"))
|
|
|
|
|
+ (tr-description (agets tr "description"))
|
|
|
|
|
+ (description (or merchant-name tr-description))
|
|
|
|
|
+ (comment (unless (equal description tr-description) tr-description))
|
|
|
|
|
+ (rcv-account (get-expense-account tr))
|
|
|
|
|
+ (rcv-amount (get-amount (* -1 (agets tr "counterpart" "amount"))))
|
|
|
|
|
+ (rcv-currency (agets tr "counterpart" "currency"))
|
|
|
|
|
+ (snd-account (format-pocket-account (find-pocket (agets tr "account" "id")
|
|
|
|
|
+ pockets)))
|
|
|
|
|
+ (snd-amount (get-amount (agets tr "amount")))
|
|
|
|
|
+ (snd-currency (agets tr "currency")))
|
|
|
|
|
+ (values description comment
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency)))
|
|
|
|
|
+
|
|
|
|
|
+(defun transaction->entry (tr pockets)
|
|
|
|
|
+ (let* ((date (get-date (agets tr "startedDate")))
|
|
|
|
|
+ (type (keyify (agets tr "type"))))
|
|
|
|
|
+ (multiple-value-bind (description comment
|
|
|
|
|
+ rcv-account rcv-amount rcv-currency
|
|
|
|
|
+ snd-account snd-amount snd-currency)
|
|
|
|
|
+ (case type
|
|
|
|
|
+ (:exchange (params-exchange tr pockets))
|
|
|
|
|
+ (:topup (params-topup tr pockets))
|
|
|
|
|
+ (:atm (params-atm tr pockets))
|
|
|
|
|
+ (:transfer (params-transfer tr pockets))
|
|
|
|
|
+ (:fee (params-fee tr pockets))
|
|
|
|
|
+ (:card-payment (params-card-payment tr pockets)))
|
|
|
|
|
+ (when snd-account
|
|
|
|
|
+ (pta-ledger:make-entry
|
|
|
|
|
+ :date date
|
|
|
|
|
+ :description description
|
|
|
|
|
+ :comment comment
|
|
|
|
|
+ :postings (list
|
|
|
|
|
+ (pta-ledger:make-posting
|
|
|
|
|
+ :account rcv-account
|
|
|
|
|
+ :amount (pta-ledger:make-amount
|
|
|
|
|
+ :quantity rcv-amount
|
|
|
|
|
+ :commodity rcv-currency)
|
|
|
|
|
+ :total-price (unless (equal rcv-currency snd-currency)
|
|
|
|
|
+ (pta-ledger:make-amount
|
|
|
|
|
+ :quantity (abs snd-amount)
|
|
|
|
|
+ :commodity snd-currency)))
|
|
|
|
|
+ (pta-ledger:make-posting
|
|
|
|
|
+ :account snd-account
|
|
|
|
|
+ :amount (pta-ledger:make-amount
|
|
|
|
|
+ :quantity snd-amount
|
|
|
|
|
+ :commodity snd-currency))))))))
|
|
|
|
|
+
|
|
|
|
|
+(defun handle-auth (login pass)
|
|
|
|
|
+ (handler-case
|
|
|
|
|
+ (progn
|
|
|
|
|
+ (poller-authenticate :revolut (cons login pass))
|
|
|
|
|
+ (handle-balance))
|
|
|
|
|
+ (poller-cant-authenticate ()
|
|
|
|
|
+ (bot-send-message "Чот не смог, пропробуй другие."))))
|
|
|
|
|
+
|
|
|
|
|
+(defun handle-balance ()
|
|
|
|
|
+ (bot-send-message
|
|
|
|
|
+ (handler-case
|
|
|
|
|
+ (let ((entries (mapcar 'format-pocket-balance (agets (get-user-wallet) "pockets"))))
|
|
|
|
|
+ (if entries (text-chunks entries) "Не нашлось"))
|
|
|
|
|
+ (poller-no-secret () "Нужен логин-пароль. /revolut <phone> <pin>")
|
|
|
|
|
+ (poller-cant-get-token () "Не смог получить данные. Попробуй перелогинься. /revolut <phone> <pin>"))
|
|
|
|
|
+ :parse-mode "markdown"))
|
|
|
|
|
+
|
|
|
|
|
+(defun prepare-entries (transactions)
|
|
|
|
|
+ (let ((pockets (agets (get-user-wallet) "pockets")))
|
|
|
|
|
+ (delete nil (mapcar (lambda (tr) (transaction->entry tr pockets)) transactions))))
|
|
|
|
|
+
|
|
|
|
|
+(defun handle-recent (&optional (count 10))
|
|
|
|
|
+ (bot-send-message
|
|
|
|
|
+ (handler-case
|
|
|
|
|
+ (format-entries (prepare-entries (get-transactions-last :count count)))
|
|
|
|
|
+ (poller-no-secret () "Нужен логин-пароль. /revolut <phone> <pin>")
|
|
|
|
|
+ (poller-cant-get-token () "Не смог получить данные. Попробуй перелогинься. /revolut <phone> <pin>"))
|
|
|
|
|
+ :parse-mode "markdown"))
|
|
|
|
|
+
|
|
|
|
|
+(def-message-cmd-handler handle-cmd-revolut (:revolut :revo)
|
|
|
|
|
+ (let ((a0 (car *args*)))
|
|
|
|
|
+ (cond
|
|
|
|
|
+ ((= 2 (length *args*)) (apply 'handle-auth *args*))
|
|
|
|
|
+ ((or (null *args*) (equal a0 "bal")) (handle-balance))
|
|
|
|
|
+ (:otherwise (handle-recent (parse-integer a0 :junk-allowed t))))))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+(defun process-new (changes)
|
|
|
|
|
+ (let ((ledger-package (find-package :chatikbot.plugins.ledger))
|
|
|
|
|
+ (transactions (prepare-entries changes)))
|
|
|
|
|
+ (if ledger-package
|
|
|
|
|
+ (let ((new-chat-entry (symbol-function
|
|
|
|
|
+ (intern "LEDGER/NEW-CHAT-ENTRY" ledger-package))))
|
|
|
|
|
+ (dolist (entry transactions)
|
|
|
|
|
+ (funcall new-chat-entry *chat-id* (pta-ledger:clone-entry entry))))
|
|
|
|
|
+ (bot-send-message (format-entries transactions) :parse-mode "markdown"))))
|
|
|
|
|
+
|
|
|
|
|
+(defcron process-revolut ()
|
|
|
|
|
+ (poller-poll-lists :revolut
|
|
|
|
|
+ #'get-transactions-last
|
|
|
|
|
+ #'process-new
|
|
|
|
|
+ :key (agetter "startedDate")))
|