1#!/usr/bin/ruby
2# frozen_string_literal: true
3
4require "bigdecimal"
5require "pg/em/connection_pool"
6require "eventmachine"
7require "em_promise"
8require "em-hiredis"
9require "dhall"
10require "sentry-ruby"
11
12Sentry.init do |config|
13 config.background_worker_threads = 0
14end
15
16SCHEMA = "{
17 xmpp: { jid: Text, password: Text },
18 component: { jid: Text },
19 keepgo: Optional { access_token: Text, api_key: Text },
20 sims: {
21 CAD: { per_gb: Natural, annual: Natural },
22 USD: { per_gb: Natural, annual: Natural }
23 },
24 plans: List {
25 currency: < CAD | USD >,
26 messages: < limited: { included: Natural, price: Natural } | unlimited >,
27 minutes: < limited: { included: Natural, price: Natural } | unlimited >,
28 monthly_price : Natural,
29 name : Text
30 }
31}"
32
33raise "Need a Dhall config" unless ARGV[0]
34
35CONFIG = Dhall::Coder
36 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
37 .load("#{ARGV.first} : #{SCHEMA}", transform_keys: :to_sym)
38
39CONFIG[:keep_area_codes_in] = {}
40CONFIG[:creds] = {}
41
42require_relative "../lib/blather_notify"
43require_relative "../lib/customer_repo"
44require_relative "../lib/low_balance"
45require_relative "../lib/postgres"
46require_relative "../lib/sim_repo"
47require_relative "../lib/transaction"
48
49CUSTOMER_REPO = CustomerRepo.new
50SIM_REPO = SIMRepo.new
51
52class JobCustomer < SimpleDelegator
53 def billing_customer
54 super.then(&self.class.method(:new))
55 end
56
57 def stanza_to(stanza)
58 stanza = stanza.dup
59 stanza.from = nil # It's a client connection, use default
60 stanza.to = Blather::JID.new(
61 "customer_#{customer_id}", CONFIG[:component][:jid]
62 ).with(resource: stanza.to&.resource)
63 block_given? ? yield(stanza) : (BlatherNotify << stanza)
64 end
65end
66
67module SimAction
68 attr_accessor :customer
69
70 def initialize(sim)
71 @sim = sim
72 end
73
74 def iccid
75 @sim.iccid
76 end
77
78 def customer_id
79 customer.customer_id
80 end
81
82 def refill_price
83 (BigDecimal(CONFIG[:sims][customer.currency][:per_gb]) / 100) * 5
84 end
85
86 def refill_and_bill(data, price, note)
87 SIM_REPO.refill(@sim, amount_mb: data).then { |keepgo_tx|
88 raise "SIM refill failed" unless keepgo_tx["ack"] == "success"
89
90 Transaction.new(
91 customer_id: customer_id,
92 transaction_id: keepgo_tx["transaction_id"],
93 amount: -price, note: note
94 ).insert
95 }.then do
96 puts "Refilled #{customer.customer_id} #{iccid}"
97 end
98 end
99
100 def monthly_limit
101 REDIS.get(
102 "jmp_customer_monthly_data_limit-#{customer_id}"
103 ).then do |limit|
104 BigDecimal(limit || refill_price)
105 end
106 end
107
108 def amount_spent
109 promise = DB.query_defer(<<~SQL, [customer_id])
110 SELECT COALESCE(SUM(amount), 0) AS a FROM transactions WHERE
111 customer_id=$1 AND transaction_id LIKE 'AB_59576_%' AND
112 created_at >= DATE_TRUNC('month', LOCALTIMESTAMP)
113 SQL
114 promise.then { |rows| rows.first["a"] }
115 end
116end
117
118class SimTopUp
119 include SimAction
120
121 def low_balance
122 LowBalance.for(customer, refill_price).then(&:notify!).then do |result|
123 next call if result.positive?
124
125 puts "Low balance #{customer.customer_id} #{iccid}"
126 end
127 end
128
129 def call
130 EMPromise.all([amount_spent, monthly_limit]).then do |(spent, limit)|
131 if spent < limit
132 next low_balance if customer.balance < refill_price
133
134 refill_and_bill(5120, refill_price, "5GB Data Topup for #{iccid}")
135 else
136 SimWarn.new(@sim).call
137 end
138 end
139 end
140end
141
142class SimWarn
143 include SimAction
144
145 def notify
146 m = Blather::Stanza::Message.new
147 m.body = "Your SIM #{iccid} only has " \
148 "#{(@sim.remaining_usage_kb / 1024).to_i} MB left"
149 customer.stanza_to(m)
150 end
151
152 def call
153 EMPromise.all([amount_spent, monthly_limit]).then do |(spent, limit)|
154 next unless spent >= limit || low_balance_and_not_auto_top_up
155
156 notify
157 puts "Data warning #{customer.customer_id} #{sim.iccid}"
158 end
159 end
160
161 def low_balance_and_not_auto_top_up
162 customer.balance < refill_price && !customer.auto_top_up_amount&.positive?
163 end
164end
165
166class SimAnnual
167 include SimAction
168
169 def notify
170 m = Blather::Stanza::Message.new
171 m.body = "Your SIM #{iccid} only has #{@sim.remaining_days} days left"
172 customer.stanza_to(m)
173 end
174
175 def annual_price
176 BigDecimal(CONFIG[:sims][customer.currency][:annual]) / 100
177 end
178
179 def call
180 if customer.balance >= annual_fee
181 refill_and_bill(1024, annual_price, "Annual fee for #{iccid}")
182 else
183 LowBalance.for(customer, refill_price).then(&:notify!).then do |result|
184 next call if result.positive?
185
186 notify
187 end
188 end
189 end
190
191 def annual_fee
192 customer.currency == :USD ? BigDecimal("5.50") : BigDecimal("7.50")
193 end
194end
195
196def fetch_customers(cids)
197 # This is gross N+1 against the DB, but also does a buch of Redis work
198 # We expect the set to be very small for the forseeable future,
199 # hundreds at most
200 EMPromise.all(
201 Set.new(cids).to_a.compact.map { |id| CUSTOMER_REPO.find(id) }
202 ).then do |customers|
203 Hash[customers.map { |c| [c.customer_id, JobCustomer.new(c)] }]
204 end
205end
206
207SIM_QUERY = "SELECT iccid, customer_id FROM sims WHERE iccid = ANY ($1)"
208def load_customers!(sims)
209 DB.query_defer(SIM_QUERY, [sims.keys]).then { |rows|
210 fetch_customers(rows.map { |row| row["customer_id"] }).then do |customers|
211 rows.each do |row|
212 sims[row["iccid"]]&.customer = customers[row["customer_id"]]
213 end
214
215 sims
216 end
217 }
218end
219
220def decide_sim_actions(sims)
221 sims.each_with_object({}) { |sim, h|
222 if sim.remaining_usage_kb < 100000
223 h[sim.iccid] = SimTopUp.new(sim)
224 elsif sim.remaining_usage_kb < 250000
225 h[sim.iccid] = SimWarn.new(sim)
226 elsif sim.remaining_days < 30
227 h[sim.iccid] = SimAnnual.new(sim)
228 end
229 }.compact
230end
231
232EM.run do
233 REDIS = EM::Hiredis.connect
234 DB = Postgres.connect(dbname: "jmp")
235
236 BlatherNotify.start(
237 CONFIG[:xmpp][:jid],
238 CONFIG[:xmpp][:password]
239 ).then { SIM_REPO.all }.then { |sims|
240 load_customers!(decide_sim_actions(sims))
241 }.then { |items|
242 items = items.values.select(&:customer)
243 EMPromise.all(items.map(&:call))
244 }.catch { |e|
245 p e
246
247 if e.is_a?(::Exception)
248 Sentry.capture_exception(e)
249 else
250 Sentry.capture_message(e.to_s)
251 end
252 }.then { EM.stop }
253end