1#!/usr/bin/ruby
2# frozen_string_literal: true
3
4# Usage: ./billing_monthly_cronjob '{
5# healthchecks_url = "https://hc-ping.com/...",
6# notify_using = {
7# jid = "",
8# password = "",
9# target = \(jid: Text) -> "+12266669977@cheogram.com",
10# body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}",
11# },
12# plans = ./plans.dhall
13# }'
14
15require "bigdecimal"
16require "date"
17require "dhall"
18require "net/http"
19require "pg"
20require "redis"
21
22require_relative "../lib/blather_notify"
23
24CONFIG = Dhall.load(<<-DHALL).sync
25 let Quota = < unlimited | limited: { included: Natural, price: Natural } >
26 let Currency = < CAD | USD >
27 in
28 (#{ARGV[0]}) : {
29 healthchecks_url: Text,
30 notify_using: { jid: Text, password: Text, target: Text -> Text },
31 plans: List {
32 name: Text,
33 currency: Currency,
34 monthly_price: Natural,
35 minutes: Quota,
36 messages: Quota
37 }
38 }
39DHALL
40
41Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {})
42
43REDIS = Redis.new
44db = PG.connect(dbname: "jmp")
45db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
46db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)
47
48BlatherNotify.start(
49 CONFIG[:notify_using][:jid],
50 CONFIG[:notify_using][:password]
51)
52
53RENEW_UNTIL = Date.today >> 1
54
55class Stats
56 def initialize(**kwargs)
57 @stats = kwargs
58 end
59
60 def add(stat, value)
61 @stats[stat] += value
62 end
63
64 def to_h
65 @stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
66 end
67end
68
69stats = Stats.new(
70 not_renewed: 0,
71 renewed: 0,
72 revenue: BigDecimal.new(0)
73)
74
75class Plan
76 def self.from_name(plan_name)
77 plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
78 new(plan) if plan
79 end
80
81 def initialize(plan)
82 @plan = plan
83 end
84
85 def price
86 BigDecimal.new(@plan["monthly_price"].to_i) * 0.0001
87 end
88
89 def bill_customer(db, customer_id)
90 transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}"
91 db.exec_params(<<-SQL, [customer_id, transaction_id, -price])
92 INSERT INTO transactions
93 (customer_id, transaction_id, amount, note)
94 VALUES
95 ($1, $2, $3, 'Renew account plan')
96 SQL
97 end
98
99 def renew(db, customer_id, expires_at)
100 bill_customer(db, customer_id)
101
102 params = [RENEW_UNTIL, customer_id, expires_at]
103 db.exec_params(<<-SQL, params)
104 UPDATE plan_log
105 SET date_range=range_merge(date_range, tsrange('now', $1))
106 WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]')
107 SQL
108 end
109end
110
111class ExpiredCustomer
112 def self.for(row)
113 plan = Plan.from_name(row["plan_name"])
114 if row["balance"] < plan.price
115 WithLowBalance.new(row, plan)
116 else
117 new(row, plan)
118 end
119 end
120
121 def initialize(row, plan)
122 @row = row
123 @plan = plan
124 end
125
126 def try_renew(db, stats)
127 @plan.renew(
128 db,
129 @row["customer_id"],
130 @row["expires_at"]
131 )
132
133 stats.add(:renewed, 1)
134 stats.add(:revenue, plan.price)
135 end
136
137 class WithLowBalance < ExpiredCustomer
138 def try_renew(_, stats)
139 jid = REDIS.get("jmp_customer_jid-#{@row['customer_id']}")
140 tel = REDIS.lindex("catapult_cred-#{jid}", 3)
141 BlatherNotify.say(
142 CONFIG[:notify_using][:target].call(jid),
143 CONFIG[:notify_using][:body].call(
144 jid, format_renewal_notification(tel)
145 )
146 )
147
148 stats.add(:not_renewed, 1)
149 end
150
151 protected
152
153 def format_renewal_notification(tel)
154 <<~NOTIFY
155 Failed to renew account for #{tel},
156 balance of #{@row['balance']} is too low.
157 To keep your number, please buy more credit soon.
158 NOTIFY
159 end
160 end
161end
162
163db.transaction do
164 db.exec(
165 <<-SQL
166 SELECT customer_id, plan_name, expires_at, balance
167 FROM customer_plans INNER JOIN balances USING (customer_id)
168 WHERE expires_at <= NOW()
169 SQL
170 ).each do |row|
171 ExpiredCustomer.for(row).try_renew(db, stats)
172 end
173end
174
175Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)