1
0

saver.lisp 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. (in-package :cl-user)
  2. (defpackage chatikbot.plugins.saver
  3. (:use :cl :chatikbot.common))
  4. (in-package :chatikbot.plugins.saver)
  5. (defsetting *saver-default-timezone* -3 "Default timezone for *saver-notify-hour* calculation. GMT+3")
  6. (defsetting *saver-notify-hour* 11 "Notify with upcoming payments and saves at this time")
  7. (defun %saver/parse-schedule (schedule)
  8. (labels ((parse (text &optional def-min def-max)
  9. (let ((dash-pos (position #\- text))
  10. (star-pos (position #\* text))
  11. (slash-pos (position #\/ text)))
  12. (if (or dash-pos star-pos slash-pos)
  13. (let ((min (if star-pos def-min
  14. (when dash-pos (parse-integer text :end dash-pos))))
  15. (max (if star-pos def-max
  16. (when dash-pos (ignore-errors (parse-integer text :start (1+ dash-pos) :end slash-pos)))))
  17. (step (or (when slash-pos (parse-integer text :start (1+ slash-pos))) 1)))
  18. (list nil min max step))
  19. (list (sort (mapcar #'parse-integer (split-sequence:split-sequence #\, text)) #'<))))))
  20. (destructuring-bind (&optional day-sched month-sched year-sched)
  21. (split-sequence:split-sequence #\Space schedule :remove-empty-subseqs t)
  22. (list (if day-sched (parse day-sched 1 31) (list nil 1 31))
  23. (if month-sched (parse month-sched 1 12) (list nil 1 12))
  24. (if year-sched (parse year-sched 1900) (list nil 1900))))))
  25. (defun %saver/get-next-time (schedule universal-time &optional (dir t))
  26. (labels ((leapp (year)
  27. (or (and (zerop (mod year 4))
  28. (not (zerop (mod year 100))))
  29. (zerop (mod year 400))))
  30. (clamp-day-rule (day-rule month year)
  31. (destructuring-bind (lst &optional (min 0) max (step 1)) day-rule
  32. (let ((max-day (case month
  33. (2 (if (leapp year) 29 28))
  34. ((1 3 5 7 8 10 12) 31)
  35. (otherwise 30))))
  36. (if (consp lst)
  37. (list (remove-duplicates (mapcar (lambda (d) (min d max-day)) lst)))
  38. (list nil (min min) (when max (min max max-day)) step)))))
  39. (next (from rule)
  40. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  41. (if (consp lst)
  42. (let ((nv (find from lst :test (if dir #'<= #'>=) :from-end (not dir))))
  43. (if nv
  44. (values nv nil)
  45. (values (car (if dir lst (last lst))) t)))
  46. (let* ((m (mod (- from min) step))
  47. (nv (if (zerop m) from
  48. (funcall (if dir #'+ #'-) from
  49. (if dir (- step m) m)))))
  50. (cond
  51. ((and max (> nv max))
  52. (if dir
  53. (values min t)
  54. (values max nil)))
  55. ((and min (< nv min))
  56. (if dir
  57. (values min nil)
  58. (values max t)))
  59. (t (values nv nil)))))))
  60. (add (v of)
  61. (funcall (if dir #'+ #'-) v (if of 1 0)))
  62. (reset-rule (rule)
  63. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  64. (if (consp lst)
  65. (car (if dir lst (last lst)))
  66. (if dir min
  67. (let ((m (mod (- max min) step)))
  68. (- max (if (zerop m) 0 m))))))))
  69. (multiple-value-bind (second minute hour day month year dow dst-p tz) (decode-universal-time universal-time)
  70. (declare (ignore second minute hour dow dst-p tz))
  71. (destructuring-bind (&optional (day-rule '(nil 1 31)) (month-rule '(nil 1 12)) (year-rule '(nil 1900)))
  72. schedule
  73. (multiple-value-bind (next-day of-day) (next (add day (not dir))
  74. (clamp-day-rule day-rule month year))
  75. (multiple-value-bind (next-month of-month) (next (add month of-day) month-rule)
  76. (multiple-value-bind (next-year of-year) (next (add year of-month) year-rule)
  77. (unless of-year
  78. (let ((next-month (if (= next-year year) next-month (reset-rule month-rule))))
  79. (encode-universal-time
  80. 0 0 0
  81. (if (and (= next-month month) (= next-year year)) next-day
  82. (reset-rule (clamp-day-rule day-rule next-month next-year)))
  83. next-month
  84. next-year))))))))))
  85. (defstruct saver/payment name amount schedule started)
  86. (defun saver/payment-income-p (payment)
  87. (> (saver/payment-amount payment) 0))
  88. (defun saver/payment-next-time (payment moment &optional (dir t))
  89. (%saver/get-next-time (%saver/parse-schedule (saver/payment-schedule payment)) moment dir))
  90. (defun saver/payment-count-events (payment from to)
  91. (loop for ut = (saver/payment-next-time payment from) then (saver/payment-next-time payment (+ ut 86400))
  92. while (< ut to)
  93. counting ut))
  94. (defun saver/find-next-payment (payments &optional (moment (get-universal-time)))
  95. (loop for payment in payments
  96. for next-time = (saver/payment-next-time payment moment)
  97. with closest-time and closest-payment
  98. when (and next-time
  99. (or (null closest-time)
  100. (< next-time closest-time)))
  101. do (setf closest-time next-time
  102. closest-payment payment)
  103. finally (return closest-payment)))
  104. (defun saver/get-expense-info (payment incomes &optional (moment (get-universal-time)))
  105. (let* ((next-payment (saver/payment-next-time payment moment))
  106. (prev-payment (max (or (saver/payment-next-time payment moment nil) 0)
  107. (or (saver/payment-started payment) 0))))
  108. (when next-payment
  109. (multiple-value-bind (total-periods total-income)
  110. (loop for income in incomes
  111. for periods = (saver/payment-count-events income prev-payment next-payment)
  112. summing periods into total-periods
  113. summing (* periods (saver/payment-amount income)) into total-income
  114. finally (return (values total-periods total-income)))
  115. (let ((payment-income-fracture (unless (zerop total-income)
  116. (/ (saver/payment-amount payment) total-income -1))))
  117. (loop for income in incomes
  118. for periods = (saver/payment-count-events income prev-payment moment)
  119. summing periods into saved-periods
  120. summing (round (* periods (saver/payment-amount income)
  121. (or payment-income-fracture 0))) into saved-amount
  122. finally (return
  123. (list :next-payment next-payment
  124. :prev-payment prev-payment
  125. :total-periods total-periods
  126. :total-income total-income
  127. :payment-income-fracture payment-income-fracture
  128. :saved-periods saved-periods
  129. :saved-amount (if (= total-periods saved-periods)
  130. (* -1 (saver/payment-amount payment))
  131. saved-amount)
  132. :left-periods (- total-periods saved-periods)
  133. :left-amount (if (= total-periods saved-periods) 0
  134. (- (- 0 (saver/payment-amount payment))
  135. saved-amount))))))))))
  136. (defun saver/get-income-info (income payments &optional (moment (get-universal-time)))
  137. (let ((incomes (remove-if-not #'saver/payment-income-p payments))
  138. (expenses (remove-if #'saver/payment-income-p payments)))
  139. (loop for payment in expenses
  140. for cur-info = (saver/get-expense-info payment incomes moment)
  141. for nxt-info = (saver/get-expense-info payment incomes (saver/payment-next-time income moment))
  142. summing (getf cur-info :saved-amount) into cur-saved
  143. summing (getf cur-info :left-amount) into cur-left
  144. summing (if (> (getf nxt-info :left-periods) 1)
  145. (round (* (saver/payment-amount income)
  146. (or (getf cur-info :payment-income-fracture) 0)))
  147. (getf nxt-info :left-amount)) into next-save
  148. finally (return
  149. (list :cur-saved cur-saved
  150. :cur-left cur-left
  151. :next-save next-save)))))
  152. (defun %saver/format-time (universal-time)
  153. (when universal-time
  154. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  155. (decode-universal-time universal-time)
  156. (declare (ignore sec min hour dow dst-p tz))
  157. (format nil "~4,'0D-~2,'0D-~2,'0D" year month day))))
  158. ;; Database
  159. (def-db-init
  160. (db-execute "create table if not exists saver_payments (chat_id, name, amount, schedule, started, notified)")
  161. (db-execute "create unique index if not exists saver_payments_chat_id_name_idx on saver_payments (chat_id, name)"))
  162. (defun %db/saver/make-payment (row)
  163. (when row
  164. (make-saver/payment :name (nth 0 row)
  165. :amount (nth 1 row)
  166. :schedule (nth 2 row)
  167. :started (nth 3 row))))
  168. (defun db/saver/get-payments (chat-id)
  169. (mapcar #'%db/saver/make-payment
  170. (db-select "select name, amount, schedule, started from saver_payments where chat_id = ? order by amount > 0, started" chat-id)))
  171. (defun db/saver/add-payment (chat-id payment)
  172. (with-slots (name amount schedule started) payment
  173. (db-execute "insert into saver_payments (chat_id, name, amount, schedule, started) values (?, ?, ?, ?, ?)"
  174. chat-id name amount schedule started)))
  175. (defun db/saver/del-payment (chat-id name)
  176. (db-execute "delete from saver_payments where chat_id = ? and name = ?" chat-id name))
  177. (defun db/saver/get-not-notified-payments (notified)
  178. (loop for row in (db-select "select name, amount, schedule, started, chat_id from saver_payments where notified is null or notified != ?" (%saver/format-time notified))
  179. collect (cons (nth 4 row) (%db/saver/make-payment row))))
  180. (defun db/saver/set-payment-notified (chat-id name moment)
  181. (db-execute "update saver_payments set notified = ? where chat_id = ? and name = ?"
  182. (%saver/format-time moment) chat-id name))
  183. ;; Cron
  184. (defun saver/find-today-payments (&optional (moment (get-universal-time)))
  185. (remove-if-not (lambda (p) (string= (%saver/format-time moment)
  186. (%saver/format-time
  187. (saver/payment-next-time p moment))))
  188. (db/saver/get-not-notified-payments moment)
  189. :key #'cdr))
  190. (defun %saver/format-expense-notification (expense moment)
  191. (format nil "'~A' надо оплатить *~A* на сумму _~$_"
  192. (saver/payment-name expense)
  193. (%saver/format-time (saver/payment-next-time expense moment))
  194. (/ (saver/payment-amount expense) -100)))
  195. (defun %saver/format-income-notification (income payments moment)
  196. (let ((income-info (saver/get-income-info income payments moment)))
  197. (format nil "*~A* ~A. Отложи _~$_!"
  198. (%saver/format-time (saver/payment-next-time income moment))
  199. (saver/payment-name income)
  200. (/ (getf income-info :next-save) 100))))
  201. (defun %saver/is-ok-to-notify (chat-id &optional (moment (get-universal-time)))
  202. (let ((tz (or (alexandria:when-let
  203. (chat-loc (aget chat-id (and (boundp '*chat-locations*) (symbol-value '*chat-locations*))))
  204. (round (- 7.5 (aget "latitude" chat-loc)) 15)) ;; Nautical time
  205. *saver-default-timezone*)))
  206. (>= (nth 2 (multiple-value-list (decode-universal-time moment tz)))
  207. *saver-notify-hour*)))
  208. (defcron process-saver (:hour '*)
  209. (let ((moment (get-universal-time)))
  210. (loop for (chat-id . payment) in (saver/find-today-payments moment)
  211. when (%saver/is-ok-to-notify chat-id moment)
  212. do (let ((payments (db/saver/get-payments chat-id)))
  213. (db-transaction
  214. (bot-send-message chat-id
  215. (if (saver/payment-income-p payment)
  216. (%saver/format-income-notification payment payments moment)
  217. (%saver/format-expense-notification payment moment))
  218. :parse-mode "markdown")
  219. (db/saver/set-payment-notified chat-id (saver/payment-name payment) moment))))))
  220. ;; Bot subcommands
  221. (defun %saver/format-info (payments &optional (moment (get-universal-time)))
  222. (let* ((expenses (remove-if #'saver/payment-income-p payments))
  223. (incomes (remove-if-not #'saver/payment-income-p payments))
  224. (expenses-info
  225. (loop for payment in expenses
  226. for idx from 1
  227. for info = (saver/get-expense-info payment incomes moment)
  228. when info
  229. collect (format nil "~D) ~A по [[~A]]: накоплено _~$_ из _~$_, осталось _~$_ к *~A* за ~A платежа"
  230. idx
  231. (saver/payment-name payment)
  232. (saver/payment-schedule payment)
  233. (/ (getf info :saved-amount) 100)
  234. (/ (saver/payment-amount payment) -100)
  235. (/ (getf info :left-amount) 100)
  236. (%saver/format-time (saver/payment-next-time payment moment))
  237. (getf info :left-periods))))
  238. (closest-expense (saver/find-next-payment expenses moment))
  239. (incomes-info
  240. (loop for payment in incomes
  241. for idx from (1+ (length expenses))
  242. for info = (saver/get-income-info payment payments moment)
  243. when info
  244. collect (format nil "~D) ~A по [[~A]]: *~A*, отложить _~$_ из _~$_"
  245. idx
  246. (saver/payment-name payment)
  247. (saver/payment-schedule payment)
  248. (%saver/format-time (saver/payment-next-time payment moment))
  249. (/ (getf info :next-save) 100)
  250. (/ (saver/payment-amount payment) 100))))
  251. (closest-income (saver/find-next-payment incomes moment))
  252. (closest-income-info (and closest-income (saver/get-income-info closest-income payments moment))))
  253. (if (or expenses-info incomes-info)
  254. (format nil "*Платежи*~%~{~A~^~%~}~%*Следующий платёж*~%'~A': *~A*, заплатить _~$_~%~%*Накопления*~%~{~A~^~%~}~%*Следующее накопление*~%'~A': *~A*, отложить _~$_~%~%*Накоплено должно быть* _~$_"
  255. expenses-info
  256. (and closest-expense (saver/payment-name closest-expense))
  257. (and closest-expense (%saver/format-time (saver/payment-next-time closest-expense moment)))
  258. (and closest-expense (/ (saver/payment-amount closest-expense) -100))
  259. incomes-info
  260. (and closest-income (saver/payment-name closest-income))
  261. (and closest-income (%saver/format-time (saver/payment-next-time closest-income moment)))
  262. (and closest-income-info (/ (getf closest-income-info :next-save) 100))
  263. (and closest-income-info (/ (getf closest-income-info :cur-saved) 100)))
  264. "Нет активных платежей.")))
  265. (defun saver/send-info (chat-id)
  266. (let ((payments (db/saver/get-payments chat-id)))
  267. (if (find t payments :key #'saver/payment-income-p)
  268. (bot-send-message chat-id (%saver/format-info payments) :parse-mode "markdown")
  269. (bot-send-message chat-id (format nil "Нет поступлений, ничего не посчитать :( /saver add ...")))))
  270. (defparameter +saver/add-scanner+ (cl-ppcre:create-scanner "^(.+?) (-?\\d+(?:\\.\\d*)?) ((?:\\d+(?:,\\d+)*(?:-(?:\\d+)?)?|\\*)(?:\\/\\d+)?(?: (?:\\d+(?:,\\d+)*(?:-(?:\\d+)?)?|\\*)(?:\\/\\d+)?){0,2})(?: (\\d{4}-\\d{2}-\\d{2}))?$"))
  271. (defun saver/add-payment (chat-id args)
  272. (multiple-value-bind (matched groups) (cl-ppcre:scan-to-strings +saver/add-scanner+ (spaced args))
  273. (if matched
  274. (let ((payment (make-saver/payment :name (elt groups 0)
  275. :amount (round (* 100 (read-from-string (elt groups 1))))
  276. :schedule (elt groups 2)
  277. :started (if (elt groups 3)
  278. (destructuring-bind (year month day)
  279. (mapcar #'parse-integer
  280. (split-sequence:split-sequence
  281. #\- (elt groups 3)))
  282. (encode-universal-time 0 0 0 day month year))
  283. (get-universal-time)))))
  284. (saver/payment-next-time payment (get-universal-time))
  285. (handler-case
  286. (db/saver/add-payment chat-id payment)
  287. (error () (send-response chat-id (format nil "Платёж '~A' уже есть!"
  288. (saver/payment-name payment)))))
  289. (saver/send-info chat-id))
  290. (send-response chat-id "Неправильно. /saver add <title> <amount> <cron> [started]"))))
  291. (defun saver/del-payment (chat-id args)
  292. (handler-case
  293. (let* ((payments (db/saver/get-payments chat-id))
  294. (payment (elt payments (1- (parse-integer (car args)))))
  295. (incomes (remove-if-not #'saver/payment-income-p payments)))
  296. (db/saver/del-payment chat-id (saver/payment-name payment))
  297. (bot-send-message chat-id
  298. (format nil "'~A' удалил.~@[ Забрать _~$_ из накопленого.~]"
  299. (saver/payment-name payment)
  300. (and (not (saver/payment-income-p payment))
  301. incomes
  302. (/ (getf (saver/get-expense-info payment incomes) :saved-amount)
  303. 100)))
  304. :parse-mode "markdown"))
  305. (error (e) (send-response chat-id (format nil "/saver del <idx> [~A]" e)))))
  306. ;; Hooks
  307. (def-message-cmd-handler handler-cmd-save (:save :saver)
  308. (if (null args)
  309. (saver/send-info chat-id)
  310. (case (keyify (car args))
  311. (:add (saver/add-payment chat-id (rest args)))
  312. (:del (saver/del-payment chat-id (rest args)))
  313. (t (send-response chat-id "Надо /saver add ... или /saver del <idx>")))))