1
0

saver.lisp 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. (in-package #:chatikbot)
  2. (defstruct saver/payment name amount schedule started)
  3. (defun saver/parse-schedule (schedule)
  4. (labels ((parse (text &optional def-min def-max)
  5. (let ((dash-pos (position #\- text))
  6. (star-pos (position #\* text))
  7. (slash-pos (position #\/ text)))
  8. (if (or dash-pos star-pos slash-pos)
  9. (let ((min (if star-pos def-min
  10. (when dash-pos (parse-integer text :end dash-pos))))
  11. (max (if star-pos def-max
  12. (when dash-pos (parse-integer text :start (1+ dash-pos) :end slash-pos))))
  13. (step (or (when slash-pos (parse-integer text :start (1+ slash-pos))) 1)))
  14. (list nil min max step))
  15. (list (sort (mapcar #'parse-integer (split-sequence:split-sequence #\, text)) #'<))))))
  16. (destructuring-bind (&optional day-sched month-sched year-sched)
  17. (split-sequence:split-sequence #\Space schedule :remove-empty-subseqs t)
  18. (list (if day-sched (parse day-sched 1 31) (list nil 1 31))
  19. (if month-sched (parse month-sched 1 12) (list nil 1 12))
  20. (if year-sched (parse year-sched 1900) (list nil 1900))))))
  21. (defun saver/get-next-time (schedule universal-time &optional (dir t))
  22. (labels ((leapp (year)
  23. (or (and (zerop (mod year 4))
  24. (not (zerop (mod year 100))))
  25. (zerop (mod year 400))))
  26. (clamp-day-rule (day-rule month year)
  27. (destructuring-bind (lst &optional (min 0) max (step 1)) day-rule
  28. (let ((max-day (case month
  29. (2 (if (leapp year) 29 28))
  30. ((1 3 5 7 8 10 12) 31)
  31. (otherwise 30))))
  32. (if (consp lst)
  33. (list (remove-duplicates (mapcar (lambda (d) (min d max-day)) lst)))
  34. (list nil (min min) (when max (min max max-day)) step)))))
  35. (next (from rule)
  36. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  37. (if (consp lst)
  38. (let ((nv (find from lst :test (if dir #'<= #'>=) :from-end (not dir))))
  39. (if nv
  40. (values nv nil)
  41. (values (car (if dir lst (last lst))) t)))
  42. (let* ((m (mod (- from min) step))
  43. (nv (if (zerop m) from
  44. (funcall (if dir #'+ #'-) from
  45. (if dir (- step m) m)))))
  46. (cond
  47. ((and max (> nv max))
  48. (if dir
  49. (values min t)
  50. (values max nil)))
  51. ((and min (< nv min))
  52. (if dir
  53. (values min nil)
  54. (values max t)))
  55. (t (values nv nil)))))))
  56. (add (v of)
  57. (funcall (if dir #'+ #'-) v (if of 1 0)))
  58. (reset-rule (rule)
  59. (destructuring-bind (lst &optional (min 0) max (step 1)) rule
  60. (if (consp lst)
  61. (car (if dir lst (last lst)))
  62. (if dir min
  63. (let ((m (mod (- max min) step)))
  64. (- max (if (zerop m) 0 m))))))))
  65. (multiple-value-bind (second minute hour day month year dow dst-p tz) (decode-universal-time universal-time)
  66. (declare (ignore second minute hour dow dst-p tz))
  67. (destructuring-bind (&optional (day-rule '(nil 1 31)) (month-rule '(nil 1 12)) (year-rule '(nil 1900)))
  68. schedule
  69. (multiple-value-bind (next-day of-day) (next (add day t) (clamp-day-rule day-rule month year))
  70. (multiple-value-bind (next-month of-month) (next (add month of-day) month-rule)
  71. (multiple-value-bind (next-year of-year) (next (add year of-month) year-rule)
  72. (unless of-year
  73. (let ((next-month (if (= next-year year) next-month (reset-rule month-rule))))
  74. (encode-universal-time
  75. 0 0 0
  76. (if (and (= next-month month) (= next-year year)) next-day
  77. (reset-rule (clamp-day-rule day-rule next-month next-year)))
  78. next-month
  79. next-year))))))))))
  80. (defun saver/count-events (from to schedule)
  81. (loop for ut = (saver/get-next-time schedule from) then (saver/get-next-time schedule ut)
  82. while (< ut to)
  83. counting ut))
  84. (defun saver/get-payment-info (payment salary &optional (moment (get-universal-time)))
  85. (let* ((salary (saver/parse-schedule salary))
  86. (schedule (saver/parse-schedule (saver/payment-schedule payment)))
  87. (next-payment (saver/get-next-time schedule moment))
  88. (prev-payment (max (saver/get-next-time schedule moment nil)
  89. (or (saver/payment-started payment) 0))))
  90. (when next-payment
  91. (let* ((total-periods (saver/count-events prev-payment next-payment salary))
  92. (saved-periods (saver/count-events prev-payment moment salary))
  93. (period-amount (floor (saver/payment-amount payment) (max 1 total-periods)))
  94. (saved-amount (* saved-periods period-amount)))
  95. (list :total-periods total-periods
  96. :period-amount period-amount
  97. :saved-periods saved-periods
  98. :saved-amount saved-amount
  99. :left-periods (- total-periods saved-periods)
  100. :left-amount (- (saver/payment-amount payment) saved-amount))))))
  101. (defun saver/get-period-info (salary payments &optional (moment (get-universal-time)))
  102. (let ((next-salary (saver/get-next-time (saver/parse-schedule salary) moment)))
  103. (when next-salary
  104. (let ((total-period-amount
  105. (loop for payment in payments
  106. for info = (saver/get-payment-info payment salary moment)
  107. summing (if (> (getf info :left-periods) 1) (getf info :period-amount) (getf info :left-amount)))))
  108. (list :next-salary next-salary :total-period-amount total-period-amount)))))
  109. ;; Database
  110. (def-db-init
  111. (db-execute "create table if not exists saver_payments (chat_id, name, amount, schedule, started, notified)")
  112. (db-execute "create unique index if not exists saver_payments_chat_id_name_idx on saver_payments (chat_id, name)")
  113. (db-execute "create table if not exists saver_salaries (chat_id, schedule, notified)")
  114. (db-execute "create unique index if not exists saver_salaries_chat_id_idx on saver_salaries (chat_id)"))
  115. (defun %db/saver/make-payment (row)
  116. (when row
  117. (make-saver/payment :name (nth 0 row)
  118. :amount (nth 1 row)
  119. :schedule (nth 2 row)
  120. :started (nth 3 row))))
  121. (defun db/saver/get-payments (chat-id)
  122. (mapcar #'%db/saver/make-payment
  123. (db-select "select name, amount, schedule, started from saver_payments where chat_id = ? order by started" chat-id)))
  124. (defun db/saver/add-payment (chat-id payment)
  125. (with-slots (name amount schedule started) payment
  126. (db-execute "insert into saver_payments (chat_id, name, amount, schedule, started) values (?, ?, ?, ?, ?)"
  127. chat-id name amount schedule started)))
  128. (defun db/saver/del-payment (chat-id name)
  129. (db-execute "delete from saver_payments where chat_id = ? and name = ?" chat-id name))
  130. (defun db/saver/get-salary (chat-id)
  131. (caar (db-select "select schedule from saver_salaries where chat_id = ?" chat-id)))
  132. (defun db/saver/set-salary (chat-id schedule)
  133. (db-transaction
  134. (db-execute "delete from saver_salaries where chat_id = ?" chat-id)
  135. (db-execute "insert into saver_salaries (chat_id, schedule) values (?, ?)" chat-id schedule)))
  136. ;; Cron
  137. ;;;; TODO
  138. ;; Hooks
  139. ;; Bot subcommands
  140. (defun %saver/format-info (payments salary)
  141. (let ((payments-info
  142. (loop for payment in payments
  143. for info = (saver/get-payment-info payment salary)
  144. collect (format nil "~A on [~A]: saved ~$ of ~$, left ~$ in ~A periods"
  145. (saver/payment-name payment)
  146. (saver/payment-schedule payment)
  147. (/ (getf info :saved-amount) 100)
  148. (/ (saver/payment-amount payment) 100)
  149. (/ (getf info :left-amount) 100)
  150. (getf info :left-periods))))
  151. (period-info (saver/get-period-info salary payments)))
  152. (multiple-value-bind (sec min hour day month year dow dst-p tz)
  153. (decode-universal-time (getf period-info :next-salary))
  154. (declare (ignore sec min hour dow dst-p tz))
  155. (format nil "~{~A~^~%~}~%~%Next save on ~4,'0D-~2,'0D-~2,'0D with ~$" payments-info
  156. year month day (/ (getf period-info :total-period-amount) 100)))))
  157. (defun saver/send-info (chat-id)
  158. (let ((payments (db/saver/get-payments chat-id))
  159. (salary (db/saver/get-salary chat-id)))
  160. (if salary
  161. (if payments
  162. (send-response chat-id (%saver/format-info payments salary))
  163. (send-response chat-id (format nil "No payments yet, salary: ~A" salary)))
  164. (send-response chat-id "No salary set, /saver salary <cron>"))))
  165. (defun saver/set-salary (chat-id args)
  166. (let ((sched (spaced args)))
  167. (saver/parse-schedule sched)
  168. (db/saver/set-salary chat-id sched)
  169. (send-response chat-id (format nil "Salary is now '~A'" sched))))
  170. (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}))?$"))
  171. (defun saver/add-payment (chat-id args)
  172. (multiple-value-bind (matched groups) (cl-ppcre:scan-to-strings +saver/add-scanner+ (spaced args))
  173. (if matched
  174. (let ((payment (make-saver/payment :name (elt groups 0)
  175. :amount (round (* 100 (read-from-string (elt groups 1))))
  176. :schedule (elt groups 2)
  177. :started (if (elt groups 3)
  178. (destructuring-bind (year month day)
  179. (mapcar #'parse-integer
  180. (split-sequence:split-sequence
  181. #\- (elt groups 3)))
  182. (encode-universal-time 0 0 0 day month year))
  183. (get-universal-time)))))
  184. (saver/parse-schedule (elt groups 2))
  185. (db/saver/add-payment chat-id payment)
  186. (saver/send-info chat-id))
  187. (send-response chat-id "Bad format. /saver add <title> <amount> <cron> [started]"))))
  188. (defun saver/del-payment (chat-id args))
  189. (def-message-cmd-handler handler-cmd-save (:save :saver)
  190. (if (null args)
  191. (saver/send-info chat-id)
  192. (case (keyify (car args))
  193. (:salary (saver/set-salary chat-id (rest args)))
  194. (:add (saver/add-payment chat-id (rest args)))
  195. (:del (saver/del-payment chat-id (rest args)))
  196. (t (send-response chat-id "Unknown command")))))