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 SET expires_at=$1
105		  WHERE customer_id=$2 AND expires_at=$3
106		SQL
107	end
108end
109
110class ExpiredCustomer
111	def self.for(row)
112		plan = Plan.from_name(row["plan_name"])
113		if row["balance"] < plan.price
114			WithLowBalance.new(row, plan)
115		else
116			new(row, plan)
117		end
118	end
119
120	def initialize(row, plan)
121		@row = row
122		@plan = plan
123	end
124
125	def try_renew(db, stats)
126		@plan.renew(
127			db,
128			@row["customer_id"],
129			@row["expires_at"]
130		)
131
132		stats.add(:renewed, 1)
133		stats.add(:revenue, plan.price)
134	end
135
136	class WithLowBalance < ExpiredCustomer
137		def try_renew(_, stats)
138			jid = REDIS.get("jmp_customer_jid-#{@row['customer_id']}")
139			tel = REDIS.lindex("catapult_cred-#{jid}", 3)
140			BlatherNotify.say(
141				CONFIG[:notify_using][:target].call(jid),
142				CONFIG[:notify_using][:body].call(
143					jid, format_renewal_notification(tel)
144				)
145			)
146
147			stats.add(:not_renewed, 1)
148		end
149
150	protected
151
152		def format_renewal_notification(tel)
153			<<~NOTIFY
154				Failed to renew account for #{tel},
155				balance of #{@row['balance']} is too low.
156				To keep your number, please buy more credit soon.
157			NOTIFY
158		end
159	end
160end
161
162db.transaction do
163	db.exec(
164		<<-SQL
165		SELECT customer_id, plan_name, expires_at, balance
166		FROM customer_plans INNER JOIN balances USING (customer_id)
167		WHERE expires_at <= NOW()
168		SQL
169	).each do |row|
170		ExpiredCustomer.for(row).try_renew(db, stats)
171	end
172end
173
174Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)