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