tinkoff.lisp 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.tinkoff
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.tinkoff)
  5. (eval-when (:compile-toplevel :load-toplevel :execute)
  6. (ql:quickload :pta-ledger))
  7. (defsetting *ua* "OnePlus ONE A2003/android: 6.0.1/TCSMB/3.4.2" "User agent")
  8. (defsetting *device-id* "1df9bdeac787e08")
  9. (defsetting *app-version* "3.4.2")
  10. (defvar *api-base-url* "https://api.tinkoff.ru/v1/")
  11. (defvar *session-id* nil "Last session id")
  12. (defvar *origin* "mobile,ib5,loyalty,platform")
  13. (defvar *platform* "android")
  14. (defvar *credentials-provider* nil "Active credentials provider")
  15. (defun api-url (method)
  16. (concatenate 'string *api-base-url* method))
  17. (defun default-params ()
  18. `(("origin" . ,*origin*)
  19. ("platform" . ,*platform*)
  20. ("deviceId" . ,*device-id*)
  21. ("appVersion" . ,*app-version*)
  22. ("sessionid" . ,*session-id*)
  23. ("ccc" . "true")))
  24. (define-condition api-error (error)
  25. ((code :initarg :code :reader api-error-code)
  26. (message :initarg :message :reader api-error-message))
  27. (:report (lambda (condition stream)
  28. (with-slots (code message) condition
  29. (format stream "Tinkoff api error ~A: ~A" code message)))))
  30. (defun request (method &key params content retry)
  31. (let* ((params (loop for (k . v) in (append (default-params) params) when v
  32. collect (cons (princ-to-string k) (princ-to-string v))))
  33. (r (json-request (api-url method) :method (if content :POST :GET)
  34. :parameters params
  35. :content content)))
  36. (if (string= "OK" (agets r "resultCode"))
  37. (agets r "payload")
  38. (let ((code (agets r "resultCode"))
  39. (message (agets r "errorMessage")))
  40. (if (and (not retry)
  41. *credentials-provider*
  42. (string-equal code "INSUFFICIENT_PRIVILEGES"))
  43. (progn
  44. (funcall *credentials-provider*
  45. (lambda (login password)
  46. (api/login login password)))
  47. (request method :params params :content content :retry t))
  48. (error 'api-error :code code :message message))))))
  49. (defun api/login (username password)
  50. (let ((new-session (request "session")))
  51. (prog1
  52. (let* ((*session-id* new-session))
  53. (request "sign_up" :params `(("username" . ,username)
  54. ("password" . ,password)))
  55. (request "level_up"))
  56. (setf *session-id* new-session))))
  57. (defun api/accounts ()
  58. (request "accounts_flat"))
  59. (defun api/operations (&key account start end)
  60. (request "operations" :params `(("account" . ,account)
  61. ("start" . ,start)
  62. ("end" . ,end))))
  63. (defvar *unix-epoch-difference*
  64. (encode-universal-time 0 0 0 1 1 1970 0))
  65. (defun universal-to-unix-time (universal-time)
  66. (- universal-time *unix-epoch-difference*))
  67. (defun unix-to-universal-time (unix-time)
  68. (+ unix-time *unix-epoch-difference*))
  69. (defun get-unix-time ()
  70. (universal-to-unix-time (get-universal-time)))
  71. (defun short-date (ms)
  72. (if ms
  73. (multiple-value-bind (sec min hour day month year)
  74. (decode-universal-time (unix-to-universal-time (round ms 1000)))
  75. (declare (ignore sec min hour))
  76. (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
  77. "-"))
  78. (defun get-op-description (op)
  79. (let ((cat (parse-integer (agets op "category" "id") :junk-allowed t)))
  80. (cond
  81. ((equal cat 16) (if (> (agets op "accountAmount" "value")
  82. 1500)
  83. "project: Volvo" "project: Smart"))
  84. (:otherwise
  85. (or
  86. (agets op "payment" "fieldsValues" "comment")
  87. (agets op "brand" "name"))))))
  88. (defun get-op-account1 (op)
  89. (case (parse-integer (agets op "account"))
  90. (5001173482 "assets:Tinkoff:Debit")
  91. (1850735 "assets:Raiffeisen:Debit")
  92. (0047479860 "liabilities:Tinkoff:Credit:Platinum")
  93. (8102961813 "assets:Tinkoff:Savings:For credit")
  94. (t (concatenate 'string "assets:Tinkoff:" (agets op "account")))))
  95. (defun get-op-account2 (op)
  96. (let ((cat (parse-integer (agets op "category" "id") :junk-allowed t)))
  97. (case cat
  98. (60 "expenses:Food:Fast-food")
  99. (36 "expenses:Transport")
  100. (32 "expenses:Food:Restaurant")
  101. (20 "expenses:Life:Wear")
  102. (16 "expenses:Transport:Car:Gas")
  103. (10 "expenses:Food:Grocery")
  104. (t (concatenate 'string
  105. (if (equal "Credit" (agets op "type"))
  106. "income:" "expenses:")
  107. (agets op "category" "name"))))))
  108. (defun ops->entry (ops)
  109. (loop for op in ops
  110. for status = (agets op "status")
  111. for date = (unix-to-universal-time (round (agets op "operationTime" "milliseconds") 1000))
  112. for payee = (agets op "description")
  113. for description = (get-op-description op)
  114. for account-amount = (agets op "accountAmount" "value")
  115. for account-currency = (agets op "accountAmount" "currency" "name")
  116. for amount = (agets op "amount" "value")
  117. for currency = (agets op "amount" "currency" "name")
  118. for account1 = (get-op-account1 op)
  119. for account2 = (get-op-account2 op)
  120. for expense = (equal "Debit" (agets op "type"))
  121. unless (string= status "FAILED")
  122. collect (pta-ledger:make-entry
  123. :date date
  124. :description payee
  125. :comment (unless (equal payee description) description)
  126. :postings (list
  127. (pta-ledger:make-posting
  128. :account account2
  129. :amount (pta-ledger:make-amount
  130. :quantity (* amount (if expense 1 -1))
  131. :commodity currency)
  132. :total-price (unless (equal currency account-currency)
  133. (pta-ledger:make-amount
  134. :quantity (* account-amount (if expense 1 -1))
  135. :commodity account-currency)))
  136. (pta-ledger:make-posting
  137. :account account1
  138. :amount (pta-ledger:make-amount
  139. :quantity (* account-amount (if expense -1 1))
  140. :commodity account-currency))))))
  141. (defun get-last-movements (begin-ut end-ut)
  142. (api/operations :start (* 1000 (universal-to-unix-time begin-ut))
  143. :end (* 1000 (universal-to-unix-time end-ut))))
  144. ;; Cron
  145. (defvar *last-entries* (make-hash-table) "Last per-chat entries")
  146. (defvar *chat-sessions* (make-hash-table) "Per-chat sessions")
  147. (defmacro with-chat-credentials ((chat-id) &body body)
  148. `(let* ((*session-id* (gethash ,chat-id *chat-sessions*))
  149. (*credentials-provider* (lambda (authenticator)
  150. (with-secret (login-pass (list :tinkoff ,chat-id))
  151. (if login-pass
  152. (apply authenticator login-pass)
  153. (error "no tinkoff credentials for ~A" ,chat-id))))))
  154. (prog1
  155. (progn
  156. ,@body)
  157. (setf (gethash ,chat-id *chat-sessions*) *session-id*))))
  158. (defun get-chat-last-entries (chat-id &optional (offset +day+))
  159. (with-chat-credentials (chat-id)
  160. (let* ((now (get-universal-time))
  161. (pre (- now offset)))
  162. (sort (ops->entry (get-last-movements pre now))
  163. #'< :key #'pta-ledger:entry-date))))
  164. (defun get-chat-accounts (chat-id)
  165. (with-chat-credentials (chat-id)
  166. (api/accounts)))
  167. (defun format-entries (changes)
  168. (format nil "```~%~{~A~^~%~%~}```" (mapcar #'pta-ledger:render changes)))
  169. (defun format-accounts (accounts)
  170. (with-output-to-string (s)
  171. (loop for a in accounts
  172. when (agets a "moneyAmount" "value")
  173. do (format s "; balance ~A ~A = ~A~A ~A~%"
  174. (short-date (agets a "lastPaymentDate" "milliseconds"))
  175. (get-op-account1 (push (cons "account" (agets a "id")) a))
  176. (if (equal "Credit" (agets a "accountType")) "-" "")
  177. (if (equal "Credit" (agets a "accountType"))
  178. (agets a "debtAmount" "value")
  179. (agets a "moneyAmount" "value"))
  180. (agets a "moneyAmount" "currency" "name")))))
  181. (defcron process-tinkoff (:minute '(member 0 5 10 15 20 25 30 35 40 45 50 55))
  182. (dolist (chat-id (lists-get :tinkoff))
  183. (let ((old (gethash chat-id *last-entries*))
  184. (new (get-chat-last-entries chat-id (* 7 24 60 60))))
  185. (when new
  186. (when old
  187. (alexandria:when-let (changes (set-difference new old :test #'equalp))
  188. (log:info changes)
  189. (bot-send-message chat-id (format-entries changes) :parse-mode "markdown")))
  190. (setf (gethash chat-id *last-entries*) new)))))
  191. (def-message-cmd-handler handler-tink (:tink)
  192. (let ((arg (car args)))
  193. (if (string= arg "bal")
  194. (bot-send-message chat-id (format-accounts (get-chat-accounts chat-id)) :parse-mode "markdown")
  195. (let ((last (get-chat-last-entries chat-id (* (if arg (parse-integer arg) 7) +day+))))
  196. (bot-send-message chat-id (format-entries last) :parse-mode "markdown")))))