raiffeisen.lisp 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.raiffeisen
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.raiffeisen)
  5. (eval-when (:compile-toplevel :load-toplevel :execute)
  6. (ql:quickload '(:cxml-stp :pta-ledger)))
  7. (defsetting *account-aliases* nil "account to ledger account")
  8. (defsetting *account-default-prefix* "assets:Raiffeisen")
  9. (defsetting *move-aliases* nil "move account aliases")
  10. (defsetting *account-cash* "assets:Cash:RUB")
  11. (defvar *raif-ws* "https://connect.raiffeisen.ru/Mobile-WS/services/" "Base WS URL")
  12. (defvar *ua* "Dalvik/2.1.0 (Linux; U; Android 6.0.1; ONE A2003 Build/MMB29M) Android/3.19.0(459)" "User agent")
  13. (defvar *soap-dom* (cxml:parse "<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><soapenv:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://entry.rconnect/xsd\" xmlns:ser=\"http://service.rconnect\" xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:soapenc=\"http://schemas.xmlsoap.org/soap/encoding/\"><soapenv:Header /><soapenv:Body></soapenv:Body></soapenv:Envelope>" (stp:make-builder)) "XML SOAP raif service envelope")
  14. (defparameter +ns-soap-env+ "http://schemas.xmlsoap.org/soap/envelope/" "Soap envelope xmlns")
  15. (defparameter +ns-rconnect+ "http://service.rconnect" "Service r-connect xmlns")
  16. (defparameter +ct-xml+ "text/xml;charset=UTF-8" "XML Content-Type")
  17. (defvar *rc-auth-service* "RCAuthorizationService")
  18. (defvar *rc-account-service* "RCAccountService")
  19. (defvar *cookie-jar* nil "Active cookie jar")
  20. (defvar *credentials-provider* nil "Active credentials provider")
  21. (defun get-service-url (service-name)
  22. (concatenate 'string *raif-ws* service-name))
  23. (defun soap-request-serialize (body)
  24. (let* ((doc (stp:copy *soap-dom*))
  25. (n-body (stp:find-recursively-if (stp:of-name "Body" +ns-soap-env+) doc)))
  26. (stp:append-child n-body (stp:copy body))
  27. (stp:serialize doc (cxml:make-string-sink))))
  28. (define-condition soap-error (error)
  29. ((name :initarg :name :reader soap-error-name)
  30. (message :initarg :message :reader soap-error-message))
  31. (:report (lambda (condition stream)
  32. (with-slots (name message) condition
  33. (format stream "Soap error ~A: ~A" name message)))))
  34. (defun %rs/login (login password)
  35. (let ((n-method (stp:make-element "ser:login" +ns-rconnect+))
  36. (n-login (stp:make-element "login"))
  37. (n-password (stp:make-element "password")))
  38. (stp:append-child n-login (stp:make-text login))
  39. (stp:append-child n-password (stp:make-text password))
  40. (stp:append-child n-method n-login)
  41. (stp:append-child n-method n-password)
  42. n-method))
  43. (defun %r-call (service request)
  44. (let* ((body
  45. (handler-case
  46. (http-request (get-service-url service)
  47. :method :post
  48. :content (soap-request-serialize request)
  49. :cookie-jar *cookie-jar*
  50. :user-agent *ua*
  51. :headers `(("SOAPAction" . "")
  52. (:content-type . ,+ct-xml+)))
  53. (dex:http-request-internal-server-error (e)
  54. (dex:response-body e))))
  55. (x/body
  56. (stp:first-child
  57. (stp:find-recursively-if
  58. (stp:of-name "Body" +ns-soap-env+)
  59. (cxml:parse body (stp:make-builder))))))
  60. (if (string-equal (stp:local-name x/body) "Fault")
  61. (let ((name (stp:local-name (stp:first-child (stp:find-recursively-if (stp:of-name "detail") x/body))))
  62. (message (stp:data (stp:first-child (stp:find-recursively-if (stp:of-name "faultstring") x/body)))))
  63. (if (and *credentials-provider*
  64. (string-equal name "sessionExpiredFault"))
  65. (progn
  66. (funcall *credentials-provider*
  67. (lambda (login password)
  68. (%r-call *rc-auth-service* (%rs/login login password))))
  69. (%r-call service request))
  70. (error 'soap-error :name name :message message)))
  71. x/body)))
  72. (defun rc/get-batch-response ()
  73. (%r-call *rc-auth-service* (stp:make-element "ser:getBatchResponse" +ns-rconnect+)))
  74. (defun get-accounts (batch-response)
  75. (stp:filter-recursively (stp:of-name "accounts") batch-response))
  76. (defun %rs/get-last-account-movements (account count)
  77. (let ((n-method (stp:make-element "ser:GetLastAccountMovements" +ns-rconnect+))
  78. (n-account (stp:copy account))
  79. (n-count (stp:make-element "count")))
  80. (setf (stp:local-name n-account) "account")
  81. (stp:append-child n-count (stp:make-text (princ-to-string count)))
  82. (stp:append-child n-method n-account)
  83. (stp:append-child n-method n-count)
  84. n-method))
  85. (defun rc/get-last-account-movements (account count)
  86. (let ((info (%r-call *rc-account-service* (%rs/get-last-account-movements account count))))
  87. (stp:filter-recursively (stp:of-name "return") info)))
  88. (defun %rs/get-account-movements (account start-date end-date)
  89. (let ((n-method (stp:make-element "ser:GetAccountMovements" +ns-rconnect+))
  90. (n-account (stp:copy account))
  91. (n-start-date (stp:make-element "startDate"))
  92. (n-end-date (stp:make-element "endDate")))
  93. (setf (stp:local-name n-account) "account")
  94. (stp:append-child n-start-date (stp:make-text start-date))
  95. (stp:append-child n-end-date (stp:make-text end-date))
  96. (stp:append-child n-method n-account)
  97. (stp:append-child n-method n-start-date)
  98. (stp:append-child n-method n-end-date)
  99. n-method))
  100. (defun rc/get-account-movements (account start-ut end-ut)
  101. (labels ((format-ut (ut)
  102. (multiple-value-bind (sec min hour day month year)
  103. (decode-universal-time ut)
  104. (format nil "~4,'0D-~2,'0D-~2,'0DT~2,'0D:~2,'0D:~2,'0D.0"
  105. year month day hour min sec))))
  106. (let ((info (%r-call *rc-account-service*
  107. (%rs/get-account-movements account
  108. (format-ut start-ut)
  109. (format-ut end-ut)))))
  110. (stp:filter-recursively (stp:of-name "return") info))))
  111. (defun tag->keyword (tag)
  112. (setf tag (cl-ppcre:regex-replace-all "([a-z])([A-Z])" tag '(0 "-" 1)))
  113. (setf tag (cl-ppcre:regex-replace-all "_" tag "-"))
  114. (intern (string-upcase tag) :keyword))
  115. (defun node->alist (node &optional keep-id)
  116. (let (res)
  117. (stp:do-children (child node (nreverse res))
  118. (let ((name (stp:local-name child)))
  119. (when (or keep-id
  120. (not (string-equal "id" name)))
  121. (push (cons (tag->keyword name)
  122. (stp:string-value child))
  123. res))))))
  124. (defun get-account-currency (account)
  125. (let ((currency (agets account :currency)))
  126. (cond
  127. ((equal currency "RUR") "RUB")
  128. (t currency))))
  129. (defun get-account-name (account)
  130. (or (agets *account-aliases* (agets account :number))
  131. (format nil "~A:~A:~A:~A"
  132. *account-default-prefix*
  133. (agets account :account-type)
  134. (agets account :number)
  135. (get-account-currency account))))
  136. (defun get-move-payee (move)
  137. (agets move :short-description))
  138. (defun get-move-description (move)
  139. (let ((full (agets move :full-description)))
  140. (unless (or (zerop (length full))
  141. (string= "CARD **" (subseq full 0 (min (length full) 7))))
  142. (cl-ppcre:regex-replace-all "\\s" full " "))))
  143. (defun get-move-account2 (move)
  144. (let ((desc (agets move :short-description)))
  145. (cond
  146. ((agets *move-aliases* desc))
  147. ((equal (subseq desc 0 (min 7 (length desc))) "RBA ATM") *account-cash*)
  148. (t (if (string= "1" (agets move :type)) "income" "expenses")))))
  149. (defun short-date (date)
  150. (cl-ppcre:regex-replace-all "-" (subseq date 0 10) "/"))
  151. (defun move->entry (move)
  152. (let* ((account (agets move :account))
  153. (account1 (get-account-name account))
  154. (account2 (get-move-account2 move))
  155. (currency (get-account-currency account))
  156. (date (pta-ledger:parse-date (short-date (agets move :commit-date))))
  157. (payee (get-move-payee move))
  158. (description (get-move-description move))
  159. (amount (parse-float (agets move :amount)))
  160. (expense (equal "0" (agets move :type))))
  161. (pta-ledger:make-entry
  162. :date date
  163. :description payee
  164. :comment description
  165. :postings (list
  166. (pta-ledger:make-posting
  167. :account account2
  168. :amount (pta-ledger:make-amount
  169. :quantity (* amount (if expense 1 -1))
  170. :commodity currency))
  171. (pta-ledger:make-posting
  172. :account account1
  173. :amount (pta-ledger:make-amount
  174. :quantity (* amount (if expense -1 1))
  175. :commodity currency))))))
  176. (defun account->balance (account)
  177. (format nil "; balance ~A ~A = ~,2F ~A"
  178. (short-date (agets account :balance-date))
  179. (get-account-name account)
  180. (parse-float (agets account :balance))
  181. (get-account-currency account)))
  182. (defun get-last-n-movements (&optional (count 10))
  183. (loop for account in (get-accounts (rc/get-batch-response))
  184. append (loop for move in (rc/get-last-account-movements account count)
  185. collect (cons (cons :account (node->alist account t))
  186. (node->alist move)))))
  187. (defun get-last-movements (begin-ut end-ut)
  188. (loop for account in (get-accounts (rc/get-batch-response))
  189. append (loop for move in (rc/get-account-movements account begin-ut end-ut)
  190. collect (cons (cons :account (node->alist account t))
  191. (node->alist move)))))
  192. (defun format-entries (changes)
  193. (text-chunks (mapcar #'pta-ledger:render changes)))
  194. (defun format-balance (accounts)
  195. (format nil "```~%~{~A~^~%~}```" (mapcar #'account->balance accounts)))
  196. ;; Cron
  197. (defvar *last-entries* (make-hash-table) "Last per-chat entries")
  198. (defvar *cookie-jars* (make-hash-table) "Per-chat cookie jars")
  199. (defmacro with-chat-credentials ((chat-id) &body body)
  200. `(let* ((*cookie-jar* (or (gethash ,chat-id *cookie-jars*)
  201. (cl-cookie:make-cookie-jar)))
  202. (*credentials-provider* (lambda (authenticator)
  203. (with-secret (login-pass (list :raiffeisen ,chat-id))
  204. (if login-pass
  205. (apply authenticator login-pass)
  206. (error "no raiffeisen credentials for ~A" ,chat-id))))))
  207. (prog1 (progn ,@body)
  208. (setf (gethash chat-id *cookie-jars*) *cookie-jar*))))
  209. (defun get-chat-last-entries (chat-id &optional (offset +day+))
  210. (with-chat-credentials (chat-id)
  211. (let* ((now (get-universal-time))
  212. (pre (- now offset)))
  213. (sort (mapcar #'move->entry (get-last-movements pre now))
  214. #'< :key #'pta-ledger:entry-date))))
  215. (defun get-chat-accounts (chat-id)
  216. (with-chat-credentials (chat-id)
  217. (mapcar #'node->alist (get-accounts (rc/get-batch-response)))))
  218. (defcron process-raiffeisen (:minute '(member 0 5 10 15 20 25 30 35 40 45 50 55))
  219. (dolist (chat-id (lists-get :raiffeisen))
  220. (let ((old (gethash chat-id *last-entries*))
  221. (new (get-chat-last-entries chat-id (* 7 +day+)))
  222. (ledger-package (find-package :chatikbot.plugins.ledger)))
  223. (when new
  224. (when old
  225. (alexandria:when-let (changes (set-difference new old :test #'equalp))
  226. (log:info changes)
  227. (if ledger-package
  228. (let ((new-chat-entry (symbol-function
  229. (intern "LEDGER/NEW-CHAT-ENTRY" ledger-package))))
  230. (dolist (entry changes)
  231. (funcall new-chat-entry chat-id (pta-ledger:clone-entry entry))))
  232. (bot-send-message chat-id (format-entries changes) :parse-mode "markdown"))))
  233. (setf (gethash chat-id *last-entries*) new)))))
  234. (def-message-cmd-handler handler-raif (:raif)
  235. (let ((arg (car args)))
  236. (if (string= arg "bal")
  237. (bot-send-message chat-id (format-balance (get-chat-accounts chat-id)) :parse-mode "markdown")
  238. (let ((last (get-chat-last-entries chat-id (* (if arg (parse-integer arg) 7) +day+))))
  239. (bot-send-message chat-id (format-entries last) :parse-mode "markdown")))))