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