1
0

tinkoff.lisp 10.0 KB

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