saver.lisp 17 KB

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