billing_monthly_cronjob

  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, settled_after, amount, note)
101			VALUES
102				($1, $2, LOCALTIMESTAMP, $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