#!/usr/bin/ruby # frozen_string_literal: true # Usage: ./billing_monthly_cronjob '{ # healthchecks_url = "https://hc-ping.com/...", # 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" CONFIG = Dhall.load(<<-DHALL).sync let Quota = < unlimited | limited: { included: Natural, price: Natural } > let Currency = < CAD | USD > in (#{ARGV[0]}) : { healthchecks_url: 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 Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {}) 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, revenue: BigDecimal.new(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.new(@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) plan = Plan.from_name(row["plan_name"]) if row["balance"] < plan.price WithLowBalance.new(row, plan) else new(row, plan) end end def initialize(row, plan) @row = row @plan = plan 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 def try_renew(_, stats) stats.add(:not_renewed, 1) 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 protected def jid REDIS.get("jmp_customer_jid-#{customer_id}") end def tel REDIS.lindex("catapult_cred-#{jid}", 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 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 #{@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| ExpiredCustomer.for(row).try_renew(db, stats) end end Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)