1#!/usr/bin/ruby
2# frozen_string_literal: true
3
4# Usage: ./billing_monthly_cronjob '{
5# notify_using = {
6# jid = "",
7# password = "",
8# target = \(jid: Text) -> "+12266669977@cheogram.com",
9# body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}",
10# },
11# plans = ./plans.dhall
12# }'
13
14require "bigdecimal"
15require "date"
16require "dhall"
17require "net/http"
18require "pg"
19require "redis"
20
21require_relative "../lib/blather_notify"
22require_relative "../lib/to_form"
23
24using ToForm
25
26CONFIG = Dhall.load(<<-DHALL).sync
27 let Quota = < unlimited | limited: { included: Natural, price: Natural } >
28 let Currency = < CAD | USD >
29 in
30 (#{ARGV[0]}) : {
31 healthchecks_url: Text,
32 sgx_jmp: Text,
33 notify_using: {
34 jid: Text,
35 password: Text,
36 target: Text -> Text,
37 body: Text -> Text -> Text
38 },
39 plans: List {
40 name: Text,
41 currency: Currency,
42 monthly_price: Natural,
43 minutes: Quota,
44 messages: Quota
45 }
46 }
47DHALL
48
49REDIS = Redis.new
50db = PG.connect(dbname: "jmp")
51db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
52db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)
53
54BlatherNotify.start(
55 CONFIG[:notify_using][:jid],
56 CONFIG[:notify_using][:password]
57)
58
59RENEW_UNTIL = Date.today >> 1
60
61class Stats
62 def initialize(**kwargs)
63 @stats = kwargs
64 end
65
66 def add(stat, value)
67 @stats[stat] += value
68 end
69
70 def to_h
71 @stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
72 end
73end
74
75stats = Stats.new(
76 not_renewed: 0,
77 renewed: 0,
78 not_registered: 0,
79 revenue: BigDecimal(0)
80)
81
82class Plan
83 def self.from_name(plan_name)
84 plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
85 new(plan) if plan
86 end
87
88 def initialize(plan)
89 @plan = plan
90 end
91
92 def price
93 BigDecimal(@plan["monthly_price"].to_i) * 0.0001
94 end
95
96 def bill_customer(db, customer_id)
97 transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}"
98 db.exec_params(<<-SQL, [customer_id, transaction_id, -price])
99 INSERT INTO transactions
100 (customer_id, transaction_id, amount, note)
101 VALUES
102 ($1, $2, $3, 'Renew account plan')
103 SQL
104 end
105
106 def renew(db, customer_id, expires_at)
107 bill_customer(db, customer_id)
108
109 params = [RENEW_UNTIL, customer_id, expires_at]
110 db.exec_params(<<-SQL, params)
111 UPDATE plan_log
112 SET date_range=range_merge(date_range, tsrange('now', $1))
113 WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]')
114 SQL
115 end
116end
117
118class ExpiredCustomer
119 def self.for(row, db)
120 plan = Plan.from_name(row["plan_name"])
121 if row["balance"] < plan.price
122 WithLowBalance.new(row, plan, db)
123 else
124 new(row, plan, db)
125 end
126 end
127
128 def initialize(row, plan, db)
129 @row = row
130 @plan = plan
131 @db = db
132 end
133
134 def customer_id
135 @row["customer_id"]
136 end
137
138 def try_renew(db, stats)
139 @plan.renew(
140 db,
141 customer_id,
142 @row["expires_at"]
143 )
144
145 stats.add(:renewed, 1)
146 stats.add(:revenue, @plan.price)
147 end
148
149 class WithLowBalance < ExpiredCustomer
150 ONE_WEEK = 60 * 60 * 24 * 7
151 LAST_WEEK = Time.now - ONE_WEEK
152
153 def try_renew(_, stats)
154 stats.add(:not_renewed, 1)
155 topup = "jmp_customer_auto_top_up_amount-#{customer_id}"
156 if REDIS.exists?(topup) && @row["expires_at"] > LAST_WEEK
157 @db.exec_params(
158 "SELECT pg_notify('low_balance', $1)",
159 [customer_id]
160 )
161 else
162 notify_if_needed
163 end
164 end
165
166 protected
167
168 def notify_if_needed
169 return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}")
170
171 REDIS.set(
172 "jmp_customer_low_balance-#{customer_id}",
173 Time.now, ex: ONE_WEEK
174 )
175 send_notification
176 end
177
178 def jid
179 REDIS.get("jmp_customer_jid-#{customer_id}")
180 end
181
182 def tel
183 REDIS.lindex("catapult_cred-customer_#{customer_id}@jmp.chat", 3)
184 end
185
186 def btc_addresses
187 @btc_addresses ||= REDIS.smembers(
188 "jmp_customer_btc_addresses-#{customer_id}"
189 )
190 end
191
192 def btc_addresses_for_notification
193 return if btc_addresses.empty?
194
195 "\nYou can buy credit by sending any amount of Bitcoin to one of "\
196 "these addresses:\n#{btc_addresses.join("\n")}"
197 end
198
199 def send_notification
200 raise "No JID for #{customer_id}, cannot notify" unless jid
201
202 BlatherNotify.say(
203 CONFIG[:notify_using][:target].call(jid),
204 CONFIG[:notify_using][:body].call(
205 jid, renewal_notification
206 )
207 )
208 end
209
210 def renewal_notification
211 "Failed to renew account for #{tel}, " \
212 "balance of $#{'%.4f' % @row['balance']} is too low. " \
213 "To keep your number, please buy more credit soon. " \
214 "#{btc_addresses_for_notification}"
215 end
216 end
217end
218
219db.transaction do
220 db.exec(
221 <<-SQL
222 SELECT customer_id, plan_name, expires_at, COALESCE(balance, 0) AS balance
223 FROM customer_plans LEFT JOIN balances USING (customer_id)
224 WHERE expires_at <= NOW()
225 SQL
226 ).each do |row|
227 one = Queue.new
228 EM.next_tick do
229 BlatherNotify.execute(
230 "customer info",
231 { q: row["customer_id"] }.to_form(:submit)
232 ).then(
233 ->(x) { one << x },
234 ->(e) { one << RuntimeError.new(e.to_s) }
235 )
236 end
237 info = one.pop
238 raise info if info.is_a?(Exception)
239
240 if info.form.field("tel")&.value
241 ExpiredCustomer.for(row, db).try_renew(db, stats)
242 else
243 stats.add(:not_registered, 1)
244 end
245 end
246end
247
248p stats