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