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