~singpolyma/jmp-pay

e7b975190d75cff8402579e8c9b0fc02f4ab2db2 — Stephen Paul Weber 3 months ago b4fbb78
Billing monthly cronjob using sgx-jmp

Just get the list of expired customers and tell sgx-jmp about each of them, wait
until all return or one errors and log result.
2 files changed, 53 insertions(+), 206 deletions(-)

M bin/billing_monthly_cronjob
M lib/blather_notify.rb
M bin/billing_monthly_cronjob => bin/billing_monthly_cronjob +49 -206
@@ 1,52 1,26 @@
#!/usr/bin/ruby
# frozen_string_literal: true

# Usage: ./billing_monthly_cronjob '{
#        notify_using = {
#          jid = "",
#          password = "",
#          target = \(jid: Text) -> "+12266669977@cheogram.com",
#          body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}",
#        },
#        plans = ./plans.dhall
#        }'

require "bigdecimal"
require "date"
require "dhall"
require "net/http"
require "pg"
require "redis"

require_relative "../lib/blather_notify"
require_relative "../lib/to_form"

using ToForm

CONFIG = Dhall.load(<<-DHALL).sync
	let Quota = < unlimited | limited: { included: Natural, price: Natural } >
	let Currency = < CAD | USD >
	in
	(#{ARGV[0]}) : {
		healthchecks_url: Text,
		sgx_jmp: Text,
		notify_using: {
			jid: Text,
			password: Text,
			target: Text -> Text,
			body: Text -> Text -> Text
		},
		plans: List {
			name: Text,
			currency: Currency,
			monthly_price: Natural,
			minutes: Quota,
			messages: Quota
		}
	}
DHALL

REDIS = Redis.new
using ToForm

db = PG.connect(dbname: "jmp")
db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)


@@ 56,193 30,62 @@ BlatherNotify.start(
	CONFIG[:notify_using][:password]
)

RENEW_UNTIL = Date.today >> 1

class Stats
	def initialize(**kwargs)
		@stats = kwargs
	end

	def add(stat, value)
		@stats[stat] += value
	end
promises = []

db.exec(
	<<-SQL
	SELECT customer_id
	FROM customer_plans
	WHERE expires_at <= LOCALTIMESTAMP + '4 days'
	SQL
).each do |row|
	EM.next_tick do
		promises << BlatherNotify.execute(
			"customer info",
			{ q: row["customer_id"] }.to_form(:submit)
		).then { |iq|
			BlatherNotify.write_with_promise(BlatherNotify.command(
				"customer info",
				iq.sessionid
			))
		}.then do |iq|
			unless iq.form.field("action")
				next "#{row["customer_id"]} not found"
			end

	def to_h
		@stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
			BlatherNotify.write_with_promise(BlatherNotify.command(
				"customer info",
				iq.sessionid,
				action: :complete,
				form: { action: "bill_plan" }.to_form(:submit)
			))
		end
	end
end

stats = Stats.new(
	not_renewed: 0,
	renewed: 0,
	not_registered: 0,
	revenue: BigDecimal(0)
)

class Plan
	def self.from_name(plan_name)
		plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
		new(plan) if plan
	end
one = Queue.new

	def initialize(plan)
		@plan = plan
	end

	def price
		BigDecimal(@plan["monthly_price"].to_i) * 0.0001
	end

	def bill_customer(db, customer_id)
		transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}"
		db.exec_params(<<-SQL, [customer_id, transaction_id, -price])
			INSERT INTO transactions
				(customer_id, transaction_id, settled_after, amount, note)
			VALUES
				($1, $2, LOCALTIMESTAMP, $3, 'Renew account plan')
		SQL
	end

	def renew(db, customer_id, expires_at)
		bill_customer(db, customer_id)

		params = [RENEW_UNTIL, customer_id, expires_at]
		db.exec_params(<<-SQL, params)
			UPDATE plan_log
			SET date_range=range_merge(date_range, tsrange('now', $1))
			WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]')
		SQL
def format(item)
	if item.respond_to?(:note) && item.note
		item.note.text
	elsif item.respond_to?(:to_xml)
		item.to_xml
	else
		item.inspect
	end
end

class ExpiredCustomer
	def self.for(row, db)
		plan = Plan.from_name(row["plan_name"])
		if row["balance"] < plan.price
			WithLowBalance.new(row, plan, db)
		else
			new(row, plan, db)
		end
	end

	def initialize(row, plan, db)
		@row = row
		@plan = plan
		@db = db
	end

	def customer_id
		@row["customer_id"]
	end

	def try_renew(db, stats)
		@plan.renew(
			db,
			customer_id,
			@row["expires_at"]
		)

		stats.add(:renewed, 1)
		stats.add(:revenue, @plan.price)
	end

	class WithLowBalance < ExpiredCustomer
		ONE_WEEK = 60 * 60 * 24 * 7
		LAST_WEEK = Time.now - ONE_WEEK

		def try_renew(_, stats)
			stats.add(:not_renewed, 1)
			topup = "jmp_customer_auto_top_up_amount-#{customer_id}"
			if REDIS.exists?(topup) && @row["expires_at"] > LAST_WEEK
				@db.exec_params(
					"SELECT pg_notify('low_balance', $1)",
					[customer_id]
				)
			else
				notify_if_needed
			end
		end

	protected

		def notify_if_needed
			return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}")

			REDIS.set(
				"jmp_customer_low_balance-#{customer_id}",
				Time.now, ex: ONE_WEEK
			)
			send_notification
		end

		def jid
			REDIS.get("jmp_customer_jid-#{customer_id}")
		end

		def tel
			REDIS.lindex("catapult_cred-customer_#{customer_id}@jmp.chat", 3)
		end

		def btc_addresses
			@btc_addresses ||= REDIS.smembers(
				"jmp_customer_btc_addresses-#{customer_id}"
			)
		end

		def btc_addresses_for_notification
			return if btc_addresses.empty?

			"\nYou can buy credit by sending any amount of Bitcoin to one of "\
			"these addresses:\n#{btc_addresses.join("\n")}"
		end

		def send_notification
			raise "No JID for #{customer_id}, cannot notify" unless jid

			BlatherNotify.say(
				CONFIG[:notify_using][:target].call(jid),
				CONFIG[:notify_using][:body].call(
					jid, renewal_notification
				)
			)
		end

		def renewal_notification
			"Failed to renew account for #{tel}, " \
			"balance of $#{'%.4f' % @row['balance']} is too low. " \
			"To keep your number, please buy more credit soon. " \
			"#{btc_addresses_for_notification}"
		end
	end
EM.add_timer(0) do
	EMPromise.all(promises).then(
		->(all) { one << all },
		->(err) { one << RuntimeError.new(format(err)) }
	)
end

db.transaction do
	db.exec(
		<<-SQL
		SELECT customer_id, plan_name, expires_at, COALESCE(balance, 0) AS balance
		FROM customer_plans LEFT JOIN balances USING (customer_id)
		WHERE expires_at <= NOW()
		SQL
	).each do |row|
		one = Queue.new
		EM.next_tick do
			BlatherNotify.execute(
				"customer info",
				{ q: row["customer_id"] }.to_form(:submit)
			).then(
				->(x) { one << x },
				->(e) { one << RuntimeError.new(e.to_s) }
			)
		end
		info = one.pop
		raise info if info.is_a?(Exception)
result = one.pop

		if info.form.field("tel")&.value
			ExpiredCustomer.for(row, db).try_renew(db, stats)
		else
			stats.add(:not_registered, 1)
		end
	end
end
raise result if result.is_a?(Exception)

p stats
result.each do |item|
	puts format(item)
end

M lib/blather_notify.rb => lib/blather_notify.rb +4 -0
@@ 42,6 42,10 @@ module BlatherNotify

	def self.write_with_promise(stanza)
		promise = EMPromise.new
		EM.add_timer(15) do
			promise.reject(:timeout)
		end

		client.write_with_handler(stanza) do |s|
			if s.error?
				promise.reject(s)