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: {
31 jid: Text,
32 password: Text,
33 target: Text -> Text,
34 body: Text -> Text -> Text
35 },
36 plans: List {
37 name: Text,
38 currency: Currency,
39 monthly_price: Natural,
40 minutes: Quota,
41 messages: Quota
42 }
43 }
44DHALL
45
46Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {})
47
48REDIS = Redis.new
49db = PG.connect(dbname: "jmp")
50db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
51db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)
52
53BlatherNotify.start(
54 CONFIG[:notify_using][:jid],
55 CONFIG[:notify_using][:password]
56)
57
58RENEW_UNTIL = Date.today >> 1
59
60class Stats
61 def initialize(**kwargs)
62 @stats = kwargs
63 end
64
65 def add(stat, value)
66 @stats[stat] += value
67 end
68
69 def to_h
70 @stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
71 end
72end
73
74stats = Stats.new(
75 not_renewed: 0,
76 renewed: 0,
77 revenue: BigDecimal.new(0)
78)
79
80class Plan
81 def self.from_name(plan_name)
82 plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
83 new(plan) if plan
84 end
85
86 def initialize(plan)
87 @plan = plan
88 end
89
90 def price
91 BigDecimal.new(@plan["monthly_price"].to_i) * 0.0001
92 end
93
94 def bill_customer(db, customer_id)
95 transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}"
96 db.exec_params(<<-SQL, [customer_id, transaction_id, -price])
97 INSERT INTO transactions
98 (customer_id, transaction_id, amount, note)
99 VALUES
100 ($1, $2, $3, 'Renew account plan')
101 SQL
102 end
103
104 def renew(db, customer_id, expires_at)
105 bill_customer(db, customer_id)
106
107 params = [RENEW_UNTIL, customer_id, expires_at]
108 db.exec_params(<<-SQL, params)
109 UPDATE plan_log
110 SET date_range=range_merge(date_range, tsrange('now', $1))
111 WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]')
112 SQL
113 end
114end
115
116class ExpiredCustomer
117 def self.for(row, db)
118 plan = Plan.from_name(row["plan_name"])
119 if row["balance"] < plan.price
120 WithLowBalance.new(row, plan, db)
121 else
122 new(row, plan, db)
123 end
124 end
125
126 def initialize(row, plan, db)
127 @row = row
128 @plan = plan
129 @db = db
130 end
131
132 def customer_id
133 @row["customer_id"]
134 end
135
136 def try_renew(db, stats)
137 @plan.renew(
138 db,
139 customer_id,
140 @row["expires_at"]
141 )
142
143 stats.add(:renewed, 1)
144 stats.add(:revenue, @plan.price)
145 end
146
147 class WithLowBalance < ExpiredCustomer
148 ONE_WEEK = 60 * 60 * 24 * 7
149 LAST_WEEK = Time.now - ONE_WEEK
150
151 def try_renew(_, stats)
152 stats.add(:not_renewed, 1)
153 if REDIS.exists?("jmp_customer_auto_top_up_amount-#{customer_id}") && \
154 @row["expires_at"] > LAST_WEEK
155 @db.exec_params("SELECT pg_notify('low_balance', $1)", [customer_id])
156 else
157 return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}")
158 REDIS.set("jmp_customer_low_balance-#{customer_id}", Time.now, ex: ONE_WEEK)
159 send_notification
160 end
161 end
162
163 protected
164
165 def jid
166 REDIS.get("jmp_customer_jid-#{customer_id}")
167 end
168
169 def tel
170 REDIS.lindex("catapult_cred-#{jid}", 3)
171 end
172
173 def btc_addresses
174 @btc_addresses ||= REDIS.smembers(
175 "jmp_customer_btc_addresses-#{customer_id}"
176 )
177 end
178
179 def btc_addresses_for_notification
180 return if btc_addresses.empty?
181 "\nYou can buy credit by sending any amount of Bitcoin to one of "\
182 "these addresses:\n#{btc_addresses.join("\n")}"
183 end
184
185 def send_notification
186 BlatherNotify.say(
187 CONFIG[:notify_using][:target].call(jid),
188 CONFIG[:notify_using][:body].call(
189 jid, renewal_notification
190 )
191 )
192 end
193
194 def renewal_notification
195 "Failed to renew account for #{tel}, " \
196 "balance of $#{'%.4f' % @row['balance']} is too low. " \
197 "To keep your number, please buy more credit soon. " \
198 "#{btc_addresses_for_notification}"
199 end
200 end
201end
202
203db.transaction do
204 db.exec(
205 <<-SQL
206 SELECT customer_id, plan_name, expires_at, COALESCE(balance, 0) AS balance
207 FROM customer_plans LEFT JOIN balances USING (customer_id)
208 WHERE expires_at <= NOW()
209 SQL
210 ).each do |row|
211 ExpiredCustomer.for(row, db).try_renew(db, stats)
212 end
213end
214
215Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)