sim_job

  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