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)