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 "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