billing_monthly_cronjob

  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: { jid: Text, password: Text, target: Text -> Text },
 31		plans: List {
 32			name: Text,
 33			currency: Currency,
 34			monthly_price: Natural,
 35			minutes: Quota,
 36			messages: Quota
 37		}
 38	}
 39DHALL
 40
 41Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {})
 42
 43REDIS = Redis.new
 44db = PG.connect(dbname: "jmp")
 45db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
 46db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)
 47
 48BlatherNotify.start(
 49	CONFIG[:notify_using][:jid],
 50	CONFIG[:notify_using][:password]
 51)
 52
 53RENEW_UNTIL = Date.today >> 1
 54
 55class Stats
 56	def initialize(**kwargs)
 57		@stats = kwargs
 58	end
 59
 60	def add(stat, value)
 61		@stats[stat] += value
 62	end
 63
 64	def to_h
 65		@stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
 66	end
 67end
 68
 69stats = Stats.new(
 70	not_renewed: 0,
 71	renewed: 0,
 72	revenue: BigDecimal.new(0)
 73)
 74
 75class Plan
 76	def self.from_name(plan_name)
 77		plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
 78		new(plan) if plan
 79	end
 80
 81	def initialize(plan)
 82		@plan = plan
 83	end
 84
 85	def price
 86		BigDecimal.new(@plan["monthly_price"].to_i) * 0.0001
 87	end
 88
 89	def bill_customer(db, customer_id)
 90		transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}"
 91		db.exec_params(<<-SQL, [customer_id, transaction_id, -price])
 92			INSERT INTO transactions
 93				(customer_id, transaction_id, amount, note)
 94			VALUES
 95				($1, $2, $3, 'Renew account plan')
 96		SQL
 97	end
 98
 99	def renew(db, customer_id, expires_at)
100		bill_customer(db, customer_id)
101
102		params = [RENEW_UNTIL, customer_id, expires_at]
103		db.exec_params(<<-SQL, params)
104			UPDATE plan_log
105			SET date_range=range_merge(date_range, tsrange('now', $1))
106			WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]')
107		SQL
108	end
109end
110
111class ExpiredCustomer
112	def self.for(row)
113		plan = Plan.from_name(row["plan_name"])
114		if row["balance"] < plan.price
115			WithLowBalance.new(row, plan)
116		else
117			new(row, plan)
118		end
119	end
120
121	def initialize(row, plan)
122		@row = row
123		@plan = plan
124	end
125
126	def try_renew(db, stats)
127		@plan.renew(
128			db,
129			@row["customer_id"],
130			@row["expires_at"]
131		)
132
133		stats.add(:renewed, 1)
134		stats.add(:revenue, plan.price)
135	end
136
137	class WithLowBalance < ExpiredCustomer
138		def try_renew(_, stats)
139			jid = REDIS.get("jmp_customer_jid-#{@row['customer_id']}")
140			tel = REDIS.lindex("catapult_cred-#{jid}", 3)
141			BlatherNotify.say(
142				CONFIG[:notify_using][:target].call(jid),
143				CONFIG[:notify_using][:body].call(
144					jid, format_renewal_notification(tel)
145				)
146			)
147
148			stats.add(:not_renewed, 1)
149		end
150
151	protected
152
153		def format_renewal_notification(tel)
154			<<~NOTIFY
155				Failed to renew account for #{tel},
156				balance of #{@row['balance']} is too low.
157				To keep your number, please buy more credit soon.
158			NOTIFY
159		end
160	end
161end
162
163db.transaction do
164	db.exec(
165		<<-SQL
166		SELECT customer_id, plan_name, expires_at, balance
167		FROM customer_plans INNER JOIN balances USING (customer_id)
168		WHERE expires_at <= NOW()
169		SQL
170	).each do |row|
171		ExpiredCustomer.for(row).try_renew(db, stats)
172	end
173end
174
175Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)