|
@@ -0,0 +1,203 @@
|
|
|
|
|
+(uiop:define-package :enikesha-scripts/tinkoff
|
|
|
|
|
+ (:use :cl)
|
|
|
|
|
+ (:mix :uiop :drakma :cl-ppcre :yason :flexi-streams)
|
|
|
|
|
+ (:export
|
|
|
|
|
+ #:main))
|
|
|
|
|
+
|
|
|
|
|
+(in-package :enikesha-scripts/tinkoff)
|
|
|
|
|
+
|
|
|
|
|
+(defparameter +session-file+ ".tinkoff.sexp" "Session file pathspec")
|
|
|
|
|
+
|
|
|
|
|
+(defvar *api-base-url* "https://api.tinkoff.ru/v1/")
|
|
|
|
|
+(defvar *ua* "OnePlus ONE A2003/android: 6.0.1/TCSMB/3.4.2" "User agent")
|
|
|
|
|
+(defvar *pin-hash* nil "Pin hash (obtain from mitmproxy")
|
|
|
|
|
+(defvar *session-id* nil "Last session id")
|
|
|
|
|
+(defvar *origin* "mobile,ib5,loyalty,platform")
|
|
|
|
|
+(defvar *platform* "android")
|
|
|
|
|
+(defvar *device-id* "1df9bdeac787e08")
|
|
|
|
|
+(defvar *app-version* "3.4.2")
|
|
|
|
|
+
|
|
|
|
|
+(defun save-session ()
|
|
|
|
|
+ (with-open-file (s +session-file+ :direction :output :if-exists :supersede :if-does-not-exist :create)
|
|
|
|
|
+ (print `(setf *pin-hash* ,*pin-hash* *session-id* ,*session-id*) s)))
|
|
|
|
|
+
|
|
|
|
|
+(defun load-session ()
|
|
|
|
|
+ (if (probe-file +session-file+)
|
|
|
|
|
+ (load +session-file+)
|
|
|
|
|
+ (error "No session file ~A" +session-file+)))
|
|
|
|
|
+
|
|
|
|
|
+;; JSON processing
|
|
|
|
|
+(defun json-request (url &key (method :get) parameters form-data content content-type additional-headers (object-as :alist))
|
|
|
|
|
+ (multiple-value-bind (stream status headers uri http-stream)
|
|
|
|
|
+ (drakma:http-request url :method method
|
|
|
|
|
+ :user-agent *ua*
|
|
|
|
|
+ :parameters parameters
|
|
|
|
|
+ :form-data form-data
|
|
|
|
|
+ :content content :content-type content-type
|
|
|
|
|
+ :additional-headers additional-headers
|
|
|
|
|
+ :external-format-out :utf-8
|
|
|
|
|
+ :force-binary t :want-stream t :decode-content t)
|
|
|
|
|
+ (declare (ignore status headers))
|
|
|
|
|
+ (unwind-protect
|
|
|
|
|
+ (progn
|
|
|
|
|
+ (setf (flex:flexi-stream-external-format stream) :utf-8)
|
|
|
|
|
+ (values (yason:parse stream :object-as object-as) uri))
|
|
|
|
|
+ (ignore-errors (close http-stream)))))
|
|
|
|
|
+
|
|
|
|
|
+(defun aget (alist &rest keys)
|
|
|
|
|
+ (loop for key in keys
|
|
|
|
|
+ for ret = (cdr (assoc key alist :test #'equal)) then (cdr (assoc key ret :test #'equal))
|
|
|
|
|
+ finally (return ret)))
|
|
|
|
|
+
|
|
|
|
|
+(defun api-url (method)
|
|
|
|
|
+ (concatenate 'string *api-base-url* method))
|
|
|
|
|
+
|
|
|
|
|
+(defun default-params ()
|
|
|
|
|
+ `(("origin" . ,*origin*)
|
|
|
|
|
+ ("platform" . ,*platform*)
|
|
|
|
|
+ ("deviceId" . ,*device-id*)
|
|
|
|
|
+ ("appVersion" . ,*app-version*)
|
|
|
|
|
+ ("sessionid" . ,*session-id*)
|
|
|
|
|
+ ("ccc" . "true")))
|
|
|
|
|
+
|
|
|
|
|
+(defun request (method &key params form-data content)
|
|
|
|
|
+ (let* ((params (loop for (k . v) in (append (default-params) params) when v
|
|
|
|
|
+ collect (cons (princ-to-string k) (princ-to-string v))))
|
|
|
|
|
+ (r (json-request (api-url method) :method (if (or form-data content) :POST :GET)
|
|
|
|
|
+ :parameters params
|
|
|
|
|
+ :form-data form-data :content content)))
|
|
|
|
|
+ (unless (string= "OK" (aget r "resultCode"))
|
|
|
|
|
+ (error "Tinkoff API error: ~A" (aget r "errorMessage")))
|
|
|
|
|
+ (aget r "payload")))
|
|
|
|
|
+
|
|
|
|
|
+(defun api/sign-up ()
|
|
|
|
|
+ (let* ((old-session *session-id*)
|
|
|
|
|
+ new-session)
|
|
|
|
|
+ (let* ((*session-id* nil)
|
|
|
|
|
+ (payload (request "sign_up" :params `(("pinHash" . ,*pin-hash*)
|
|
|
|
|
+ ("oldSessionId" . ,old-session)
|
|
|
|
|
+ ("auth_type" . "pin")
|
|
|
|
|
+ ("auth_type_set_date" . "0")))))
|
|
|
|
|
+ (setf new-session (aget payload "sessionId")))
|
|
|
|
|
+ (setf *session-id* new-session)
|
|
|
|
|
+ (prog1
|
|
|
|
|
+ (request "level_up" :params `(("auth_type" . "pin") ("auth_type_set_date" . "0")))
|
|
|
|
|
+ (save-session))))
|
|
|
|
|
+
|
|
|
|
|
+(defun api/accounts ()
|
|
|
|
|
+ (request "accounts_flat"))
|
|
|
|
|
+
|
|
|
|
|
+(defun api/operations (&key account start end)
|
|
|
|
|
+ (request "operations" :params `(("account" . ,account)
|
|
|
|
|
+ ("start" . ,start)
|
|
|
|
|
+ ("end" . ,end))))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-account-name (account)
|
|
|
|
|
+ (let ((num (aget account :number)))
|
|
|
|
|
+ (concatenate 'string "assets:Raiffeisen:"
|
|
|
|
|
+ (cond
|
|
|
|
|
+ ((string= num "40817810503000266700") "Debit")
|
|
|
|
|
+ ((string= num "40817978803000110883") "Savings:EUR")
|
|
|
|
|
+ ((string= num "40817810203001534278") "Savings:Renov")
|
|
|
|
|
+ ((string= num "40817810103001667025") "Savings:For credit")
|
|
|
|
|
+ (:overwise (concatenate 'string
|
|
|
|
|
+ (aget account :account-type)
|
|
|
|
|
+ ":"
|
|
|
|
|
+ num
|
|
|
|
|
+ ":"
|
|
|
|
|
+ (aget account :currency)))))))
|
|
|
|
|
+
|
|
|
|
|
+(defvar *unix-epoch-difference*
|
|
|
|
|
+ (encode-universal-time 0 0 0 1 1 1970 0))
|
|
|
|
|
+
|
|
|
|
|
+(defun universal-to-unix-time (universal-time)
|
|
|
|
|
+ (- universal-time *unix-epoch-difference*))
|
|
|
|
|
+
|
|
|
|
|
+(defun unix-to-universal-time (unix-time)
|
|
|
|
|
+ (+ unix-time *unix-epoch-difference*))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-unix-time ()
|
|
|
|
|
+ (universal-to-unix-time (get-universal-time)))
|
|
|
|
|
+
|
|
|
|
|
+(defun short-date (ms)
|
|
|
|
|
+ (if ms
|
|
|
|
|
+ (multiple-value-bind (sec min hour day month year)
|
|
|
|
|
+ (decode-universal-time (unix-to-universal-time (round ms 1000)))
|
|
|
|
|
+ (declare (ignore sec min hour))
|
|
|
|
|
+ (format nil "~4,'0D/~2,'0D/~2,'0D" year month day))
|
|
|
|
|
+ "-"))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-op-description (op)
|
|
|
|
|
+ (let ((cat (parse-integer (aget op "category" "id"))))
|
|
|
|
|
+ (cond
|
|
|
|
|
+ ((= cat 16) (if (> (aget op "accountAmount" "value")
|
|
|
|
|
+ 1500)
|
|
|
|
|
+ "project: Volvo" "project: Smart"))
|
|
|
|
|
+ (:otherwise
|
|
|
|
|
+ (or
|
|
|
|
|
+ (aget op "payment" "fieldsValues" "comment")
|
|
|
|
|
+ (aget op "brand" "name"))))))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-op-account1 (op)
|
|
|
|
|
+ (case (parse-integer (aget op "account"))
|
|
|
|
|
+ (5001173482 "assets:Tinkoff:Debit")
|
|
|
|
|
+ (1850735 "assets:Raiffeisen:Debit")
|
|
|
|
|
+ (0047479860 "liabilities:Tinkoff:Credit:Platinum")
|
|
|
|
|
+ (8102961813 "assets:Tinkoff:Savings:For credit")
|
|
|
|
|
+ (t (concatenate 'string "assets:Tinkoff:" (aget op "account")))))
|
|
|
|
|
+
|
|
|
|
|
+(defun get-op-account2 (op)
|
|
|
|
|
+ (let ((cat (parse-integer (aget op "category" "id"))))
|
|
|
|
|
+ (case cat
|
|
|
|
|
+ (60 "expenses:Food:Fast-food")
|
|
|
|
|
+ (36 "expenses:Transport")
|
|
|
|
|
+ (32 "expenses:Food:Restaurant")
|
|
|
|
|
+ (20 "expenses:Life:Wear")
|
|
|
|
|
+ (16 "expenses:Transport:Car:Gas")
|
|
|
|
|
+ (10 "expenses:Food:Grocery")
|
|
|
|
|
+ (t (concatenate 'string
|
|
|
|
|
+ (if (equal "Credit" (aget op "type"))
|
|
|
|
|
+ "income:" "expenses:")
|
|
|
|
|
+ (aget op "category" "name"))))))
|
|
|
|
|
+
|
|
|
|
|
+(defun ops->journal (ops)
|
|
|
|
|
+ (loop for op in ops
|
|
|
|
|
+ for date = (short-date (aget op "operationTime" "milliseconds"))
|
|
|
|
|
+ for payee = (aget op "description")
|
|
|
|
|
+ for description = (get-op-description op)
|
|
|
|
|
+ for account-amount = (aget op "accountAmount" "value")
|
|
|
|
|
+ for account-currency = (aget op "accountAmount" "currency" "name")
|
|
|
|
|
+ for amount = (aget op "amount" "value")
|
|
|
|
|
+ for currency = (aget op "amount" "currency" "name")
|
|
|
|
|
+ for account1 = (get-op-account1 op)
|
|
|
|
|
+ for account2 = (get-op-account2 op)
|
|
|
|
|
+ for expense = (equal "Debit" (aget op "type"))
|
|
|
|
|
+ collect (format nil "~A ~A~@[ ; ~A~]~% ~38A ~A~A ~A~% ~38A ~A~A ~A~[ @@ ~A~A ~A~]"
|
|
|
|
|
+ date payee (unless (equal payee description) description)
|
|
|
|
|
+ account1 (if expense "-" " ") account-amount account-currency
|
|
|
|
|
+ account2 (if expense " " "-") account-amount account-currency
|
|
|
|
|
+ (not (equal currency account-currency)) (if expense " " "-") amount currency)))
|
|
|
|
|
+
|
|
|
|
|
+(defparameter +day+ (* 24 60 60) "Seconds in day")
|
|
|
|
|
+
|
|
|
|
|
+(defun main (argv)
|
|
|
|
|
+ (load-session)
|
|
|
|
|
+ (api/sign-up)
|
|
|
|
|
+ (let* ((period (car argv))
|
|
|
|
|
+ (end (get-unix-time))
|
|
|
|
|
+ (start (- end
|
|
|
|
|
+ (cond
|
|
|
|
|
+ ((equal "day" period) +day+)
|
|
|
|
|
+ ((equal "month" period) (* 30 +day+))
|
|
|
|
|
+ (:otherwise (* 7 +day+)))))
|
|
|
|
|
+ (ops (api/operations :start (* start 1000) :end (* end 1000))))
|
|
|
|
|
+ (format t "~{~A~^~%~%~}~%" (ops->journal (nreverse ops))))
|
|
|
|
|
+ (loop for a in (api/accounts)
|
|
|
|
|
+ do (format t "; balance ~A ~A = ~A~A ~A~%"
|
|
|
|
|
+ (short-date (aget a "lastPaymentDate" "milliseconds"))
|
|
|
|
|
+ (get-op-account1 (push (cons "account" (aget a "id")) a))
|
|
|
|
|
+ (if (equal "Credit" (aget a "accountType")) "-" "")
|
|
|
|
|
+ (if (equal "Credit" (aget a "accountType"))
|
|
|
|
|
+ (aget a "debtAmount" "value")
|
|
|
|
|
+ (aget a "moneyAmount" "value"))
|
|
|
|
|
+ (aget a "moneyAmount" "currency" "name"))))
|