ledger.lisp 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.ledger
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.ledger)
  5. (eval-when (:compile-toplevel :load-toplevel :execute)
  6. (ql:quickload '(:pta-ledger :legit)))
  7. (defvar *ledger/chat-journals* (make-hash-table))
  8. (defvar *git-repo-locks* (make-hash-table :test #'equal))
  9. (defun git-get-repo-lock (repo)
  10. (let ((key (legit:location repo)))
  11. (setf key
  12. (etypecase key
  13. (string key)
  14. (pathname (namestring key))))
  15. (or (gethash key *git-repo-locks*)
  16. (setf (gethash key *git-repo-locks*)
  17. (bt:make-recursive-lock key)))))
  18. (defun git-get-repo (location remote)
  19. (let ((repo (make-instance 'legit:repository :location location)))
  20. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  21. (legit:init repo :remote remote :if-does-not-exist :clone))
  22. repo))
  23. (defsetting *git-repos-root* "/tmp/ledger-repos/")
  24. (defun git-get-chat-location (chat-id remote)
  25. (namestring (uiop:ensure-directory-pathname
  26. (merge-pathnames (format nil "~A-~A" chat-id (token-hmac remote))
  27. *git-repos-root*))))
  28. (defun git-read-latest-file (repo path)
  29. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  30. (legit:fetch repo :branch "master" :remote "origin")
  31. (legit:reset repo :hard t :to "origin/master")
  32. (uiop:read-file-string (merge-pathnames path (legit:location repo)))))
  33. (defun ledger/refresh-git (chat-id remote path)
  34. (let* ((location (git-get-chat-location chat-id remote))
  35. (repo (git-get-repo location remote))
  36. (content (git-read-latest-file repo path))
  37. (journal (pta-ledger:parse-journal content))
  38. (updated (legit:current-age repo)))
  39. (setf (gethash chat-id *ledger/chat-journals*)
  40. (cons journal updated))))
  41. (defun git-append-latest-file (repo path text message)
  42. (bt:with-recursive-lock-held ((git-get-repo-lock repo))
  43. (let ((repo-path (merge-pathnames path (legit:location repo))))
  44. (dotimes (tries 5)
  45. (let ((current (or (ignore-errors (git-read-latest-file repo path)) "")))
  46. (uiop/stream:with-output-file (s repo-path
  47. :if-exists :supersede
  48. :if-does-not-exist :create)
  49. (format s "~A~A" current text)))
  50. (legit:add repo (namestring repo-path))
  51. (legit:commit repo message)
  52. (handler-case
  53. (progn
  54. (legit:push repo)
  55. (return-from git-append-latest-file path))
  56. (legit:git-error ())))
  57. (error "Tried 5 times to push ~A to ~A" path (legit:remote-url repo)))))
  58. (defun ledger/add-git (chat-id remote path text message)
  59. (let* ((location (git-get-chat-location chat-id remote))
  60. (repo (git-get-repo location remote)))
  61. (git-append-latest-file repo path
  62. (format nil "~%~A~%" text)
  63. message)))
  64. (defun ledger/get-hook-url (chat-id)
  65. (get-webhook-url "ledger" chat-id (token-hmac (write-to-string chat-id))))
  66. (defun ledger/format-uri (url)
  67. (let ((uri (quri:uri url)))
  68. (quri:render-uri (quri:make-uri :userinfo (when (quri:uri-userinfo uri) "*:*")
  69. :defaults url))))
  70. (defun ledger/format-time (universal-time)
  71. (when universal-time
  72. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  73. (decode-universal-time universal-time)
  74. (declare (ignore dow dst-p tz))
  75. (format nil "~4,'0D-~2,'0D-~2,'0D ~2,'0D:~2,'0D:~2,'0D"
  76. year month day hour min sec))))
  77. (defun format-date (ut)
  78. (multiple-value-bind (sec min hour day month year)
  79. (decode-universal-time ut)
  80. (declare (ignore sec min hour))
  81. (format nil "~4,'0D-~2,'0D-~2,'0D"
  82. year month day)))
  83. (defun get-year (ut)
  84. (multiple-value-bind (sec min hour day month year)
  85. (decode-universal-time ut)
  86. (declare (ignore sec min hour day month))
  87. year))
  88. (defun ledger/refresh-uri (chat-id uri)
  89. (setf (gethash chat-id *ledger/chat-journals*)
  90. (cons (pta-ledger:parse-journal (http-request uri))
  91. (get-universal-time))))
  92. (defun get-chat-journal-info (chat-id &optional info)
  93. (labels ((process-info (info)
  94. (cond
  95. ((stringp info) (ledger/refresh-uri chat-id info))
  96. ((consp info) (apply #'ledger/refresh-git chat-id info))
  97. (:otherwise nil))))
  98. (let ((journal-info (gethash chat-id *ledger/chat-journals*)))
  99. (if journal-info journal-info
  100. (if info (process-info info)
  101. (with-secret (info (list :ledger chat-id))
  102. (process-info info)))))))
  103. (defun ledger/handle-info ()
  104. (with-secret (info (list :ledger *chat-id*))
  105. (if info
  106. (destructuring-bind (journal . ut)
  107. (get-chat-journal-info *chat-id* info)
  108. (let ((uri (cond
  109. ((consp info) (car info))
  110. ((stringp info) info))))
  111. (bot-send-message (format nil "Журнал с ~D записями, из ~A, обновлён ~A.~%Веб-хук: ~A"
  112. (length journal)
  113. (ledger/format-uri uri)
  114. (ledger/format-time ut)
  115. (ledger/get-hook-url *chat-id*))
  116. :disable-web-preview t)))
  117. (bot-send-message "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>"))))
  118. (defun ledger/handle-set-info (info)
  119. (setf (gethash *chat-id* *ledger/chat-journals*) nil)
  120. (handler-case
  121. (destructuring-bind (journal . ut)
  122. (get-chat-journal-info *chat-id* info)
  123. (declare (ignore ut))
  124. (secret-set (list :ledger *chat-id*) info)
  125. (bot-send-message (format nil "Добавил журнал с ~D записями. Веб-хук для обновления: ~A"
  126. (length journal)
  127. (ledger/get-hook-url *chat-id*))))
  128. (pta-ledger:journal-failed (e)
  129. (bot-send-message (format nil "Не смог спарсить: ~A" e)))
  130. (dex:http-request-failed (e)
  131. (bot-send-message (format nil "Не смог в урл: ~A" (dex:response-body e))))))
  132. (def-message-cmd-handler handler-ledger (:ledger)
  133. (cond
  134. ((<= 1 (length *args*) 2) (ledger/handle-set-info *args*))
  135. (:otherwise (ledger/handle-info))))
  136. (defmacro with-chat-journal ((chat-id journal updated) &body body)
  137. (let ((info (gensym "info")))
  138. `(let ((,info (get-chat-journal-info ,chat-id)))
  139. (if ,info
  140. (destructuring-bind (,journal . ,updated) ,info
  141. (declare (ignorable ,journal ,updated))
  142. ,@body)
  143. (bot-send-message "Добавь журнал: uri - /ledger <url>; git - /ledger <remote> <path>"
  144. :chat-id ,chat-id)))))
  145. (defun ledger/handle-balance (query)
  146. (with-chat-journal (*chat-id* journal updated)
  147. (bot-send-message (text-chunks (split-sequence:split-sequence
  148. #\Newline
  149. (or (pta-ledger:journal-balance journal query)
  150. "Не нашлось"))
  151. :text-sep "
  152. ")
  153. :parse-mode "markdown")))
  154. (def-message-cmd-handler handler-balance (:balance :bal :budget)
  155. (cond
  156. ((null *args*) (ledger/handle-balance (if (eql *cmd* :budget) "budget" "assets")))
  157. (:otherwise (ledger/handle-balance (spaced *args*)))))
  158. (defun ledger/handle-journal (query)
  159. (with-chat-journal (*chat-id* journal updated)
  160. (let* ((pta-ledger:*posting-length* 40)
  161. (entries (pta-ledger:journal-print journal query))
  162. (len (length entries))
  163. (count (min len 50)))
  164. (bot-send-message (if entries
  165. (text-chunks (subseq entries (- len count) len))
  166. "Не нашлось")
  167. :parse-mode "markdown"))))
  168. (def-message-cmd-handler handler-journal (:journal)
  169. (cond
  170. ((null *args*) (ledger/handle-journal "date:thisweek"))
  171. (:otherwise (ledger/handle-journal (spaced *args*)))))
  172. (def-message-cmd-handler hander-add-report (:add_report)
  173. (bot-send-message "Введи название"
  174. :reply-markup (telegram-force-reply))
  175. (on-next-message
  176. (let ((name *text*))
  177. (bot-send-message "Введи запрос"
  178. :reply-markup (telegram-force-reply))
  179. (on-next-message
  180. (let ((query *text*))
  181. (pta-ledger:parse-query query) ;; Validate query
  182. (bot-send-message "Введи расписание, ex: (:day-of-week 0 :hour 10), (:day * :hour 10)"
  183. :reply-markup (telegram-force-reply))
  184. (on-next-message
  185. (let* ((schedule *text*))
  186. (add-chat-cron :ledger-report *chat-id* schedule name query)
  187. (bot-send-message "Добавил"))))))))
  188. (defun run-report (chat-id name query)
  189. (with-chat-journal (chat-id journal updated)
  190. (let ((balance (pta-ledger:journal-balance journal query)))
  191. (when balance
  192. (bot-send-message (text-chunks (split-sequence:split-sequence
  193. #\Newline
  194. (format nil "*~A*~%~%~A" name balance))
  195. :text-sep "
  196. ")
  197. :parse-mode "markdown"
  198. :chat-id chat-id))
  199. t)))
  200. (def-chat-cron-handler handler-chat-cron (:ledger-report chat-id schedule name query)
  201. (run-report chat-id name query))
  202. (def-message-cmd-handler handler-reports (:reports)
  203. (let ((crons (get-chat-crons :ledger-report *chat-id*)))
  204. (if crons
  205. (bot-send-message
  206. (format nil "Отчёты~%~%~{~A) ~A _~A_: ~A~^~%~}"
  207. (loop for (schedule name query) in crons
  208. for idx from 1
  209. append (list idx name schedule query)))
  210. :parse-mode "markdown"
  211. :reply-markup
  212. (get-inline-keyboard
  213. (append
  214. (loop for (schedule name query) in crons
  215. for idx from 0
  216. collect
  217. (let ((cron-index idx)
  218. (cron-name name))
  219. (list (inline-button ((format nil "Удалить '~A'" cron-name))
  220. (delete-chat-cron :ledger-report *source-chat-id* cron-index)
  221. (telegram-edit-message-reply-markup
  222. nil :chat-id *source-chat-id* :message-id *source-message-id*)
  223. (bot-send-message (format nil "Удалил *~A*" cron-name)
  224. :chat-id *source-chat-id*
  225. :parse-mode "markdown")))))
  226. (list (list (inline-button ("Отмена")
  227. (telegram-edit-message-reply-markup
  228. nil :chat-id *source-chat-id* :message-id *source-message-id*)))))))
  229. (bot-send-message
  230. "Отчётов пока нет"
  231. :reply-markup (telegram-reply-keyboard-markup (list (list (list :text "/add_report")))
  232. :one-time-keyboard t)))))
  233. (defun match-entry (text journal)
  234. (labels ((two-post (entries)
  235. (remove-if-not #'(lambda (e)
  236. (= 2 (length (pta-ledger:entry-postings e))))
  237. entries)))
  238. (let ((desc (two-post
  239. (pta-ledger:journal-entries journal (format nil "desc:'~A'" text)))))
  240. (if desc (car (last desc))
  241. (let ((comment (two-post
  242. (pta-ledger:journal-entries journal (format nil "comment:'~A'" text)))))
  243. (when comment (car (last comment))))))))
  244. (defvar *expenses-account-root* "expenses" "Expenses accounts root.")
  245. (defun create-entry (chat-id text amount currency &optional account)
  246. (with-chat-journal (chat-id journal updated)
  247. (let* ((old (match-entry text journal))
  248. (new (or (when old (pta-ledger:clone-entry old))
  249. (pta-ledger:make-entry
  250. :description text
  251. :postings (list
  252. (pta-ledger:make-posting :account *expenses-account-root*)
  253. (pta-ledger:make-posting)))))
  254. (postings (pta-ledger:entry-postings new)))
  255. (setf (pta-ledger:entry-date new) (get-universal-time)
  256. (pta-ledger:posting-amount (car postings)) (pta-ledger:make-amount
  257. :quantity amount
  258. :commodity currency)
  259. (pta-ledger:posting-amount (cadr postings)) (pta-ledger:make-amount
  260. :quantity (- amount)
  261. :commodity currency)
  262. (pta-ledger:posting-account (cadr postings)) (or account (format nil "assets:cash:~(~A~)" currency)))
  263. new)))
  264. (defun find-posting (entry root)
  265. (when (and entry root)
  266. (find root (pta-ledger:entry-postings entry)
  267. :test #'equal
  268. :key #'(lambda (p)
  269. (car (pta-ledger:account-parents
  270. (pta-ledger:posting-account p) :tree :t))))))
  271. (defvar *budget-account-root* "budget" "Budget accounts root. Extend entry with matching budget if set")
  272. (defvar *budget-search-date-query* "date:last30" "Date part of matching budget search")
  273. (defun extend-entry! (chat-id entry)
  274. (let ((expense (find-posting entry *expenses-account-root*))
  275. (budget (find-posting entry *budget-account-root*)))
  276. (when (and *budget-account-root* expense (or (null budget)
  277. (equal #\! (pta-ledger:posting-status budget))))
  278. (with-chat-journal (chat-id journal updated)
  279. (let* ((last-entries (pta-ledger:journal-entries journal (format nil "~A acct:^~A"
  280. *budget-search-date-query*
  281. (pta-ledger:posting-account expense))))
  282. (last-budget (some #'(lambda (e) (find-posting e *budget-account-root*))
  283. (reverse last-entries)))
  284. (expense-amount (car (pta-ledger:get-amounts expense (pta-ledger:entry-postings entry) :t))))
  285. (when last-budget
  286. (unless budget
  287. (setf budget (pta-ledger:make-posting :status #\! :virtual #\())
  288. (setf (pta-ledger:entry-postings entry)
  289. (append (pta-ledger:entry-postings entry)
  290. (list budget))))
  291. (setf (pta-ledger:posting-amount budget) (pta-ledger:make-amount
  292. :quantity (- (pta-ledger:amount-quantity expense-amount))
  293. :commodity (pta-ledger:amount-commodity expense-amount))
  294. (pta-ledger:posting-account budget) (pta-ledger:posting-account last-budget))))))
  295. entry))
  296. (def-webhook-handler ledger/handle-webhook ("ledger")
  297. (when (= 2 (length *paths*))
  298. (destructuring-bind (chat-id hmac) *paths*
  299. (let ((true-hmac (token-hmac chat-id)))
  300. (when (string= true-hmac hmac)
  301. (with-secret (info (list :ledger chat-id))
  302. (when info
  303. (let ((chat-id (parse-integer chat-id)))
  304. (log:info "Updating ledger for ~A" chat-id)
  305. (setf (gethash chat-id *ledger/chat-journals*) nil)
  306. (get-chat-journal-info chat-id info)
  307. "OK"))))))))
  308. ;; New entries
  309. (defun format-entry (entry)
  310. (let ((pta-ledger:*posting-length* 40))
  311. (format nil "```~%~A```" (pta-ledger:render entry))))
  312. (defun ledger/format-add-entry-message (from)
  313. (format nil "Ledger add from ~A ~A at ~A"
  314. (aget "first_name" from) (aget "last_name" from)
  315. (ledger/format-time (get-universal-time))))
  316. (defun ledger/process-add-entry (chat-id from entry)
  317. (with-secret (info (list :ledger chat-id))
  318. (if info
  319. (cond
  320. ((consp info)
  321. (handler-case (progn
  322. (ledger/add-git
  323. chat-id
  324. (car info) (cadr info)
  325. (pta-ledger:render entry)
  326. (ledger/format-add-entry-message from))
  327. (show-harshly))
  328. (error (e)
  329. (log:error "~A" e)
  330. "Не смог :(")))
  331. (:otherwise "Добавляю только в git журнал :("))
  332. "Добавь git журнал.")))
  333. (defun ledger/new-chat-entry (chat-id entry)
  334. (let ((entry (extend-entry! chat-id entry)))
  335. (bot-send-message
  336. (format-entry entry)
  337. :chat-id chat-id
  338. :parse-mode "markdown"
  339. :reply-markup (keyboard/entry entry))))
  340. (defun entry-edit (chat-id message-id entry)
  341. (let ((entry (extend-entry! chat-id entry)))
  342. (telegram-edit-message-text
  343. (format-entry entry)
  344. :chat-id chat-id :message-id message-id
  345. :parse-mode "markdown"
  346. :reply-markup (keyboard/edit-entry chat-id message-id entry))))
  347. (defun merge-cheque (entry cheque)
  348. (labels ((positive-posting (p)
  349. (plusp (pta-ledger:amount-quantity (pta-ledger:posting-amount p)))))
  350. (let ((postings (append
  351. (remove-if-not #'positive-posting (pta-ledger:entry-postings cheque))
  352. (remove-if #'positive-posting (pta-ledger:entry-postings entry))))
  353. (default-account (symbol-value (intern "*DEFAULT-EXPENSE-ACCOUNT*"
  354. (find-package :chatikbot.plugins.ofd))))
  355. (entry-positive (car (remove-if-not #'positive-posting (pta-ledger:entry-postings entry)))))
  356. (log:info entry cheque entry-positive postings)
  357. (when (zerop (pta-ledger:amount-quantity
  358. (car (pta-ledger::complement-amounts postings t))))
  359. ;; Keep expense accounts if not specified in cheque
  360. (when entry-positive
  361. (loop for p in postings
  362. when (positive-posting p)
  363. when (equalp (pta-ledger:posting-account p) default-account)
  364. do (setf (pta-ledger:posting-account p)
  365. (pta-ledger:posting-account entry-positive))))
  366. (setf (pta-ledger:entry-postings entry) postings)
  367. ;; Take comment from cheque if set
  368. (alexandria:when-let (comment (pta-ledger:entry-comment cheque))
  369. (setf (pta-ledger:entry-comment entry) comment))
  370. entry))))
  371. (defun keyboard/entry (entry)
  372. (let* ((ofd-package (find-package :chatikbot.plugins.ofd))
  373. (handle-next-cheque (when ofd-package (symbol-function (intern "HANDLE-NEXT-CHEQUE" ofd-package)))))
  374. (get-inline-keyboard
  375. (list
  376. (list (inline-button ("➕")
  377. (telegram-answer-callback-query *query-id* :text "Добавляю...")
  378. (telegram-send-message *source-chat-id* (ledger/process-add-entry
  379. *source-chat-id* *from* entry))
  380. (telegram-edit-message-reply-markup
  381. nil :chat-id *source-chat-id* :message-id *source-message-id*))
  382. (inline-button ("💲")
  383. (entry-edit *source-chat-id* *source-message-id* entry))
  384. (when handle-next-cheque
  385. (inline-button ("🧾")
  386. (let ((query-id *query-id*)
  387. (source-chat-id *source-chat-id*)
  388. (source-message-id *source-message-id*))
  389. (telegram-answer-callback-query query-id :text "Жду чек")
  390. (funcall handle-next-cheque
  391. (lambda (cheque-entry)
  392. (bot-send-message (format nil "Получил чек на сумму ~A"
  393. (pta-ledger:entry-total-amount cheque-entry)))
  394. (let ((merged (merge-cheque entry cheque-entry)))
  395. (if merged
  396. (telegram-edit-message-text
  397. (format-entry merged)
  398. :chat-id source-chat-id
  399. :message-id source-message-id
  400. :parse-mode "markdown"
  401. :reply-markup (keyboard/entry entry))
  402. (bot-send-message "Суммы не сходятся :("))))))))
  403. (inline-button ("✖️")
  404. (telegram-delete-message *source-chat-id* *source-message-id*)))))))
  405. (defun def (str default)
  406. (if (or (null str) (string= "" str)) default
  407. str))
  408. (defun keyboard/edit-entry (chat-id message-id entry)
  409. (get-inline-keyboard
  410. (append
  411. (list
  412. (list (inline-button ((format-date (pta-ledger:entry-date entry)))
  413. (bot-send-message "Введите дату" :chat-id chat-id
  414. :reply-markup (telegram-force-reply))
  415. (on-next-message
  416. (let ((date (pta-ledger:parse-date *text* (get-year
  417. (pta-ledger:entry-date entry)))))
  418. (if date
  419. (progn
  420. (setf (pta-ledger:entry-date entry) date)
  421. (entry-edit chat-id message-id entry))
  422. (bot-send-message "Не разобрал" :chat-id chat-id)))))
  423. (inline-button ((def (pta-ledger:entry-description entry) "Описание"))
  424. (bot-send-message "Введите описание" :chat-id chat-id
  425. :reply-markup (telegram-force-reply))
  426. (on-next-message
  427. (setf (pta-ledger:entry-description entry) *text*)
  428. (entry-edit chat-id message-id entry)))
  429. (inline-button ((def (pta-ledger:entry-comment entry) "Коммент"))
  430. (bot-send-message "Введите комментарий" :chat-id chat-id
  431. :reply-markup (telegram-force-reply))
  432. (on-next-message
  433. (setf (pta-ledger:entry-comment entry) *text*)
  434. (entry-edit chat-id message-id entry)))))
  435. (loop for posting in (pta-ledger:entry-postings entry)
  436. collect
  437. (let ((this-posting posting))
  438. (list (inline-button ((pta-ledger:posting-account posting))
  439. (account-edit chat-id *source-message-id* entry this-posting
  440. (pta-ledger:posting-account this-posting)))
  441. (inline-button ((def (pta-ledger:render
  442. (pta-ledger:posting-amount posting))
  443. "Сумма"))
  444. (bot-send-message "Введите сумму" :chat-id chat-id
  445. :reply-markup (telegram-force-reply))
  446. (on-next-message
  447. (let ((amount (pta-ledger:parse-amount *text*)))
  448. (setf (pta-ledger:posting-amount this-posting) amount
  449. (pta-ledger:posting-status this-posting) nil)
  450. (entry-edit chat-id message-id entry))))
  451. (inline-button ("❌")
  452. (setf (pta-ledger:entry-postings entry)
  453. (remove this-posting (pta-ledger:entry-postings entry)
  454. :test #'equalp))
  455. (entry-edit chat-id *source-message-id* entry)))))
  456. (list (list (inline-button ("Готово")
  457. (telegram-edit-message-reply-markup
  458. (keyboard/entry entry)
  459. :chat-id chat-id :message-id *source-message-id*)))))))
  460. (defun accounts/nav (account accounts &optional (offset 0) (count 5))
  461. (let* ((len (1+ (length account)))
  462. (sep-pos (position #\: account :from-end t))
  463. (parent (when sep-pos (subseq account 0 sep-pos)))
  464. (head (when account (concatenate 'string account ":")))
  465. (descendants (remove-if #'(lambda (a)
  466. (when head
  467. (not (equal head
  468. (subseq a 0 (min (length a) len))))))
  469. accounts)))
  470. (if descendants
  471. (let* ((children (sort (remove-duplicates
  472. (mapcar #'(lambda (d)
  473. (subseq d 0 (or (position #\: d :start len)
  474. (length d))))
  475. descendants)
  476. :test #'equal) #'string<))
  477. (total (length children)))
  478. (when (stringp offset)
  479. (let ((off (position offset children :test #'equal)))
  480. (setf offset (if off (max 0 (- off (round count 2))) 0))))
  481. (values account parent
  482. (mapcar
  483. #'(lambda (c)
  484. (let* ((needle (concatenate 'string c ":"))
  485. (n-l (length needle)))
  486. (cons c
  487. (not
  488. (find needle accounts :test #'equal
  489. :key #'(lambda (a)
  490. (subseq a 0 (min n-l (length a)))))))))
  491. (subseq children offset
  492. (min total (+ offset count))))
  493. (unless (zerop offset) (max 0 (- offset count)))
  494. (when (< (+ offset count) total) (+ offset count))))
  495. (when parent (accounts/nav parent accounts account)))))
  496. (defun account-edit (chat-id message-id entry posting account &optional (offset 0))
  497. (with-chat-journal (chat-id journal updated)
  498. (let* ((accounts (pta-ledger:journal-accounts journal))
  499. (nav-list (multiple-value-list
  500. (accounts/nav account accounts offset))))
  501. (telegram-edit-message-reply-markup
  502. (apply #'keyboard/account chat-id message-id entry posting nav-list)
  503. :chat-id chat-id :message-id message-id))))
  504. (defun keyboard/account (chat-id message-id entry posting account parent children prev next)
  505. (get-inline-keyboard
  506. (append
  507. (list (list (when account
  508. (inline-button (account)
  509. (setf (pta-ledger:posting-account posting) account
  510. (pta-ledger:posting-status posting) nil)
  511. (entry-edit chat-id *source-message-id* entry)))
  512. (inline-button ("Ввести")
  513. (bot-send-message "Введите счёт" :chat-id chat-id
  514. :reply-markup (telegram-force-reply))
  515. (on-next-message
  516. (let ((account (pta-ledger:parse-account *text*)))
  517. (if account
  518. (progn
  519. (setf (pta-ledger:posting-account posting) account
  520. (pta-ledger:posting-status posting) nil)
  521. (entry-edit chat-id message-id entry))
  522. (bot-send-message "Не разобрал" :chat-id chat-id)))))))
  523. (loop for (acc . leaf) in children
  524. collect (let ((this-account acc))
  525. (list (if leaf
  526. (inline-button (this-account)
  527. (setf (pta-ledger:posting-account posting) this-account
  528. (pta-ledger:posting-status posting) nil)
  529. (entry-edit chat-id *source-message-id* entry))
  530. (inline-button ((format nil "~A ..." this-account))
  531. (account-edit chat-id *source-message-id* entry posting
  532. this-account))))))
  533. (list (list (when prev
  534. (inline-button ("<<")
  535. (account-edit chat-id *source-message-id* entry posting
  536. account prev)))
  537. (when (or parent account)
  538. (inline-button ((or parent "ACC"))
  539. (account-edit chat-id *source-message-id* entry posting
  540. parent account)))
  541. (when next
  542. (inline-button (">>")
  543. (account-edit chat-id *source-message-id* entry posting
  544. account next))))))))
  545. (def-message-cmd-handler handler-create (:rub :usd :eur :thb :btc :czk :fuy)
  546. (cond
  547. ((>= (length *args*) 2)
  548. (ledger/new-chat-entry *chat-id* (create-entry *chat-id*
  549. (spaced (subseq *args* 1))
  550. (parse-float (car *args*))
  551. (case *cmd*
  552. (:fuy "CZK")
  553. (t (symbol-name *cmd*)))
  554. (case *cmd*
  555. (:fuy "liabilities:people:fuy")))))
  556. (:otherwise (bot-send-message (format nil "/~A <amount> <description>" *cmd*)))))
  557. (defun get-harshly-state (journal account day next-day)
  558. (labels ((get-account-balance (query)
  559. (car (gethash account
  560. (apply #'pta-ledger:balance
  561. (pta-ledger::entries journal)
  562. (pta-ledger::parse-query query))))))
  563. (let* ((left-days (ceiling (- next-day day) 86400))
  564. (morning-balance (get-account-balance (format nil "~A date:-~A" account (format-date day))))
  565. (balance (get-account-balance (format nil "~A date:-~A" account (format-date (+ day pta-ledger::+day+)))))
  566. (today-amount (get-account-balance (format nil "amt:<0 ~A date:~A" account (format-date day))))
  567. (daily-amount (if (zerop left-days) balance
  568. (pta-ledger:make-amount
  569. :quantity (/ (pta-ledger:amount-quantity morning-balance) left-days)
  570. :commodity (pta-ledger:amount-commodity morning-balance))))
  571. (left-today (pta-ledger:make-amount
  572. :quantity (+ (pta-ledger:amount-quantity daily-amount)
  573. (if today-amount (pta-ledger:amount-quantity today-amount) 0))
  574. :commodity (pta-ledger:amount-commodity morning-balance)))
  575. (overspent-today (< (pta-ledger:amount-quantity left-today) 0))
  576. (overspent-daily (when (and overspent-today (> left-days 0))
  577. (pta-ledger:make-amount
  578. :quantity (/ (pta-ledger:amount-quantity balance) (1- left-days))
  579. :commodity (pta-ledger:amount-commodity balance)))))
  580. (list overspent-today left-days balance daily-amount today-amount left-today overspent-daily))))
  581. (defun format-harshly-state (state)
  582. (destructuring-bind (overspent-today left-days balance daily-amount today-amount left-today overspent-daily)
  583. state
  584. (if overspent-today
  585. (apply #'format nil "До зарплаты ~A дн, осталось ~A. Сегодня покутил на ~a и осталось теперь по ~a в день"
  586. left-days
  587. (mapcar #'pta-ledger:render (list balance today-amount overspent-daily)))
  588. (if today-amount
  589. (apply #'format nil "До зарплаты ~a дн, осталось ~A. Это по ~A в день. Сегодня уже слито ~a и осталось на сегодня ~A."
  590. left-days
  591. (mapcar #'pta-ledger:render (list balance daily-amount today-amount left-today)))
  592. (apply #'format nil "До зарплаты ~a дн, осталось ~A. Это по ~A в день, на сегодня полностью."
  593. left-days
  594. (mapcar #'pta-ledger:render (list balance daily-amount)))))))
  595. (defvar *harshly-account* "assets:revolut:czk")
  596. (defvar *harshly-payday-schedule* '(:day-of-month (member 8)))
  597. (defun show-harshly ()
  598. (with-chat-journal (*chat-id* journal updated)
  599. (format-harshly-state
  600. (get-harshly-state
  601. journal *harshly-account*
  602. (get-universal-time)
  603. (clon:next-time (apply #'clon:make-typed-cron-schedule
  604. *harshly-payday-schedule*))))))
  605. (def-message-cmd-handler handler-harshly (:harshly :harsh)
  606. (bot-send-message (show-harshly)))