tinkoff.lisp 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. (uiop:define-package :enikesha-scripts/tinkoff
  2. (:use :cl)
  3. (:mix :uiop :drakma :cl-ppcre :yason :flexi-streams)
  4. (:export
  5. #:main))
  6. (in-package :enikesha-scripts/tinkoff)
  7. (defparameter +session-file+ ".tinkoff.sexp" "Session file pathspec")
  8. (defvar *api-base-url* "https://api.tinkoff.ru/v1/")
  9. (defvar *ua* "OnePlus ONE A2003/android: 6.0.1/TCSMB/3.4.2" "User agent")
  10. (defvar *pin-hash* nil "Pin hash (obtain from mitmproxy")
  11. (defvar *session-id* nil "Last session id")
  12. (defvar *origin* "mobile,ib5,loyalty,platform")
  13. (defvar *platform* "android")
  14. (defvar *device-id* "1df9bdeac787e08")
  15. (defvar *app-version* "3.4.2")
  16. (defun save-session ()
  17. (with-open-file (s +session-file+ :direction :output :if-exists :supersede :if-does-not-exist :create)
  18. (print `(setf *pin-hash* ,*pin-hash* *session-id* ,*session-id*) s)))
  19. (defun load-session ()
  20. (if (probe-file +session-file+)
  21. (load +session-file+)
  22. (error "No session file ~A" +session-file+)))
  23. ;; JSON processing
  24. (defun json-request (url &key (method :get) parameters form-data content content-type additional-headers (object-as :alist))
  25. (multiple-value-bind (stream status headers uri http-stream)
  26. (drakma:http-request url :method method
  27. :user-agent *ua*
  28. :parameters parameters
  29. :form-data form-data
  30. :content content :content-type content-type
  31. :additional-headers additional-headers
  32. :external-format-out :utf-8
  33. :force-binary t :want-stream t :decode-content t)
  34. (declare (ignore status headers))
  35. (unwind-protect
  36. (progn
  37. (setf (flex:flexi-stream-external-format stream) :utf-8)
  38. (values (yason:parse stream :object-as object-as) uri))
  39. (ignore-errors (close http-stream)))))
  40. (defun aget (alist &rest keys)
  41. (loop for key in keys
  42. for ret = (cdr (assoc key alist :test #'equal)) then (cdr (assoc key ret :test #'equal))
  43. finally (return ret)))
  44. (defun api-url (method)
  45. (concatenate 'string *api-base-url* method))
  46. (defun default-params ()
  47. `(("origin" . ,*origin*)
  48. ("platform" . ,*platform*)
  49. ("deviceId" . ,*device-id*)
  50. ("appVersion" . ,*app-version*)
  51. ("sessionid" . ,*session-id*)
  52. ("ccc" . "true")))
  53. (defun request (method &key params form-data content)
  54. (let* ((params (loop for (k . v) in (append (default-params) params) when v
  55. collect (cons (princ-to-string k) (princ-to-string v))))
  56. (r (json-request (api-url method) :method (if (or form-data content) :POST :GET)
  57. :parameters params
  58. :form-data form-data :content content)))
  59. (unless (string= "OK" (aget r "resultCode"))
  60. (error "Tinkoff API error: ~A" (aget r "errorMessage")))
  61. (aget r "payload")))
  62. (defun api/sign-up ()
  63. (let* ((old-session *session-id*)
  64. new-session)
  65. (let* ((*session-id* nil)
  66. (payload (request "sign_up" :params `(("pinHash" . ,*pin-hash*)
  67. ("oldSessionId" . ,old-session)
  68. ("auth_type" . "pin")
  69. ("auth_type_set_date" . "0")))))
  70. (setf new-session (aget payload "sessionId")))
  71. (setf *session-id* new-session)
  72. (prog1
  73. (request "level_up" :params `(("auth_type" . "pin") ("auth_type_set_date" . "0")))
  74. (save-session))))
  75. (defun api/accounts ()
  76. (request "accounts_flat"))
  77. (defun api/operations (&key account start end)
  78. (request "operations" :params `(("account" . ,account)
  79. ("start" . ,start)
  80. ("end" . ,end))))
  81. (defvar *unix-epoch-difference*
  82. (encode-universal-time 0 0 0 1 1 1970 0))
  83. (defun universal-to-unix-time (universal-time)
  84. (- universal-time *unix-epoch-difference*))
  85. (defun unix-to-universal-time (unix-time)
  86. (+ unix-time *unix-epoch-difference*))
  87. (defun get-unix-time ()
  88. (universal-to-unix-time (get-universal-time)))
  89. (defun short-date (ms)
  90. (if ms
  91. (multiple-value-bind (sec min hour day month year)
  92. (decode-universal-time (unix-to-universal-time (round ms 1000)))
  93. (declare (ignore sec min hour))
  94. (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
  95. "-"))
  96. (defun get-op-description (op)
  97. (let ((cat (parse-integer (aget op "category" "id") :junk-allowed t)))
  98. (cond
  99. ((equal cat 16) (if (> (aget op "accountAmount" "value")
  100. 1500)
  101. "project: Volvo" "project: Smart"))
  102. (:otherwise
  103. (or
  104. (aget op "payment" "fieldsValues" "comment")
  105. (aget op "brand" "name"))))))
  106. (defun get-op-account1 (op)
  107. (case (parse-integer (aget op "account"))
  108. (5001173482 "assets:Tinkoff:Debit")
  109. (1850735 "assets:Raiffeisen:Debit")
  110. (0047479860 "liabilities:Tinkoff:Credit:Platinum")
  111. (8102961813 "assets:Tinkoff:Savings:For credit")
  112. (t (concatenate 'string "assets:Tinkoff:" (aget op "account")))))
  113. (defun get-op-account2 (op)
  114. (let ((cat (parse-integer (aget op "category" "id") :junk-allowed t)))
  115. (case cat
  116. (60 "expenses:Food:Fast-food")
  117. (36 "expenses:Transport")
  118. (32 "expenses:Food:Restaurant")
  119. (20 "expenses:Life:Wear")
  120. (16 "expenses:Transport:Car:Gas")
  121. (10 "expenses:Food:Grocery")
  122. (t (concatenate 'string
  123. (if (equal "Credit" (aget op "type"))
  124. "income:" "expenses:")
  125. (aget op "category" "name"))))))
  126. (defun ops->journal (ops)
  127. (loop for op in ops
  128. for date = (short-date (aget op "operationTime" "milliseconds"))
  129. for payee = (aget op "description")
  130. for description = (get-op-description op)
  131. for account-amount = (aget op "accountAmount" "value")
  132. for account-currency = (aget op "accountAmount" "currency" "name")
  133. for amount = (aget op "amount" "value")
  134. for currency = (aget op "amount" "currency" "name")
  135. for account1 = (get-op-account1 op)
  136. for account2 = (get-op-account2 op)
  137. for expense = (equal "Debit" (aget op "type"))
  138. collect (format nil "~A ~A~@[ ; ~A~]~% ~38A ~A~,2F ~A~@[ @@ ~{~A~,2F ~A~}~]~% ~38A ~A~,2F ~A"
  139. date payee (unless (equal payee description) description)
  140. account2 (if expense " " "-") amount currency
  141. (unless (equal currency account-currency)
  142. (list (if expense "" "-") account-amount account-currency))
  143. account1 (if expense "-" " ") account-amount account-currency)))
  144. (defparameter +day+ (* 24 60 60) "Seconds in day")
  145. (defun main (argv)
  146. (load-session)
  147. (api/sign-up)
  148. (let* ((period (car argv))
  149. (end (get-unix-time))
  150. (start (- end
  151. (cond
  152. ((equal "day" period) +day+)
  153. ((equal "month" period) (* 30 +day+))
  154. (:otherwise (* 7 +day+)))))
  155. (ops (api/operations :start (* start 1000) :end (* end 1000))))
  156. (format t "~{~A~^~%~%~}~%" (ops->journal (nreverse ops))))
  157. (loop for a in (api/accounts)
  158. when (aget a "moneyAmount" "value")
  159. do (format t "; balance ~A ~A = ~A~A ~A~%"
  160. (short-date (aget a "lastPaymentDate" "milliseconds"))
  161. (get-op-account1 (push (cons "account" (aget a "id")) a))
  162. (if (equal "Credit" (aget a "accountType")) "-" "")
  163. (if (equal "Credit" (aget a "accountType"))
  164. (aget a "debtAmount" "value")
  165. (aget a "moneyAmount" "value"))
  166. (aget a "moneyAmount" "currency" "name"))))