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