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