#!/usr/bin/ruby # frozen_string_literal: true # Usage: ./billing_monthly_cronjob '{ # notify_using = { # jid = "", # password = "", # target = \(jid: Text) -> "+12266669977@cheogram.com", # body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}", # }, # plans = ./plans.dhall # }' require "bigdecimal" require "date" require "dhall" require "net/http" require "pg" require "redis" require_relative "../lib/blather_notify" require_relative "../lib/to_form" using ToForm CONFIG = Dhall.load(<<-DHALL).sync let Quota = < unlimited | limited: { included: Natural, price: Natural } > let Currency = < CAD | USD > in (#{ARGV[0]}) : { healthchecks_url: Text, sgx_jmp: Text, notify_using: { jid: Text, password: Text, target: Text -> Text, body: Text -> Text -> Text }, plans: List { name: Text, currency: Currency, monthly_price: Natural, minutes: Quota, messages: Quota } } DHALL REDIS = Redis.new db = PG.connect(dbname: "jmp") db.type_map_for_results = PG::BasicTypeMapForResults.new(db) db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db) BlatherNotify.start( CONFIG[:notify_using][:jid], CONFIG[:notify_using][:password] ) RENEW_UNTIL = Date.today >> 1 class Stats def initialize(**kwargs) @stats = kwargs end def add(stat, value) @stats[stat] += value end def to_h @stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v } end end stats = Stats.new( not_renewed: 0, renewed: 0, not_registered: 0, revenue: BigDecimal(0) ) class Plan def self.from_name(plan_name) plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name } new(plan) if plan end def initialize(plan) @plan = plan end def price BigDecimal(@plan["monthly_price"].to_i) * 0.0001 end def bill_customer(db, customer_id) transaction_id = "#{customer_id}-renew-until-#{RENEW_UNTIL}" db.exec_params(<<-SQL, [customer_id, transaction_id, -price]) INSERT INTO transactions (customer_id, transaction_id, amount, note) VALUES ($1, $2, $3, 'Renew account plan') SQL end def renew(db, customer_id, expires_at) bill_customer(db, customer_id) params = [RENEW_UNTIL, customer_id, expires_at] db.exec_params(<<-SQL, params) UPDATE plan_log SET date_range=range_merge(date_range, tsrange('now', $1)) WHERE customer_id=$2 AND date_range -|- tsrange($3, $3, '[]') SQL end end class ExpiredCustomer def self.for(row, db) plan = Plan.from_name(row["plan_name"]) if row["balance"] < plan.price WithLowBalance.new(row, plan, db) else new(row, plan, db) end end def initialize(row, plan, db) @row = row @plan = plan @db = db end def customer_id @row["customer_id"] end def try_renew(db, stats) @plan.renew( db, customer_id, @row["expires_at"] ) stats.add(:renewed, 1) stats.add(:revenue, @plan.price) end class WithLowBalance < ExpiredCustomer ONE_WEEK = 60 * 60 * 24 * 7 LAST_WEEK = Time.now - ONE_WEEK def try_renew(_, stats) stats.add(:not_renewed, 1) topup = "jmp_customer_auto_top_up_amount-#{customer_id}" if REDIS.exists?(topup) && @row["expires_at"] > LAST_WEEK @db.exec_params( "SELECT pg_notify('low_balance', $1)", [customer_id] ) else notify_if_needed end end protected def notify_if_needed return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}") REDIS.set( "jmp_customer_low_balance-#{customer_id}", Time.now, ex: ONE_WEEK ) send_notification end def jid REDIS.get("jmp_customer_jid-#{customer_id}") end def tel REDIS.lindex("catapult_cred-customer_#{customer_id}@jmp.chat", 3) end def btc_addresses @btc_addresses ||= REDIS.smembers( "jmp_customer_btc_addresses-#{customer_id}" ) end def btc_addresses_for_notification return if btc_addresses.empty? "\nYou can buy credit by sending any amount of Bitcoin to one of "\ "these addresses:\n#{btc_addresses.join("\n")}" end def send_notification raise "No JID for #{customer_id}, cannot notify" unless jid BlatherNotify.say( CONFIG[:notify_using][:target].call(jid), CONFIG[:notify_using][:body].call( jid, renewal_notification ) ) end def renewal_notification "Failed to renew account for #{tel}, " \ "balance of $#{'%.4f' % @row['balance']} is too low. " \ "To keep your number, please buy more credit soon. " \ "#{btc_addresses_for_notification}" end end end db.transaction do db.exec( <<-SQL SELECT customer_id, plan_name, expires_at, COALESCE(balance, 0) AS balance FROM customer_plans LEFT JOIN balances USING (customer_id) WHERE expires_at <= NOW() SQL ).each do |row| one = Queue.new EM.next_tick do BlatherNotify.execute( "customer info", { q: row["customer_id"] }.to_form(:submit) ).then( ->(x) { one << x }, ->(e) { one << RuntimeError.new(e.to_s) } ) end info = one.pop raise info if info.is_a?(Exception) if info.form.field("tel")&.value ExpiredCustomer.for(row, db).try_renew(db, stats) else stats.add(:not_registered, 1) end end end p stats