(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)))) (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") :junk-allowed t))) (cond ((equal 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") :junk-allowed t))) (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~,2F ~A~@[ @@ ~{~A~,2F ~A~}~]~% ~38A ~A~,2F ~A" date payee (unless (equal payee description) description) account2 (if expense " " "-") amount currency (unless (equal currency account-currency) (list (if expense "" "-") account-amount account-currency)) account1 (if expense "-" " ") account-amount account-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) when (aget a "moneyAmount" "value") 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"))))