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"
 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