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, customer: nil)
 87		@sim = sim
 88		@customer = customer
 89	end
 90
 91	def iccid
 92		@sim.iccid
 93	end
 94
 95	def customer_id
 96		customer.customer_id
 97	end
 98
 99	def refill_price
100		(BigDecimal(CONFIG[:sims][customer.currency][:per_gb]) / 100) * 5
101	end
102
103	def refill_and_bill(data, price, note)
104		SIM_REPO.refill(@sim, amount_mb: data).then { |keepgo_tx|
105			raise "SIM refill failed: #{iccid}" unless keepgo_tx["ack"] == "success"
106
107			Transaction.new(
108				customer_id: customer_id,
109				transaction_id: keepgo_tx["transaction_id"],
110				amount: -price, note: note
111			).insert
112		}.then do
113			LOG.info "Refilled #{customer.customer_id} #{iccid}"
114		end
115	end
116
117	def monthly_limit
118		REDIS.get(
119			"jmp_customer_monthly_data_limit-#{customer_id}"
120		).then do |limit|
121			BigDecimal(limit || refill_price)
122		end
123	end
124
125	def amount_spent
126		promise = DB.query_defer(<<~SQL, [customer_id])
127			SELECT COALESCE(SUM(amount), 0) AS a FROM transactions WHERE
128			customer_id=$1 AND transaction_id LIKE 'AB_59576_%' AND
129			created_at >= DATE_TRUNC('month', LOCALTIMESTAMP)
130		SQL
131		promise.then { |rows| -rows.first["a"] }
132	end
133end
134
135class SimTopUp
136	include SimAction
137
138	def low_balance
139		LowBalance.for(customer, refill_price).then(&:notify!).then do |result|
140			@customer = customer.with_balance(customer.balance + result)
141			next call if result.positive?
142
143			LOG.info "Low balance #{customer.customer_id} #{iccid}"
144		end
145	end
146
147	def call
148		EMPromise.all([amount_spent, monthly_limit]).then do |(spent, limit)|
149			if spent < limit
150				next low_balance if customer.balance < refill_price
151
152				refill_and_bill(5120, refill_price, "5GB Data Topup for #{iccid}")
153			else
154				SimWarn.new(@sim, customer: customer).call
155			end
156		end
157	end
158end
159
160class SimWarn
161	include SimAction
162
163	def notify
164		m = Blather::Stanza::Message.new
165		m.body = "Your SIM #{iccid} only has " \
166		         "#{(@sim.remaining_usage_kb / 1024).to_i} MB left"
167		customer.stanza_to(m)
168	end
169
170	def call
171		EMPromise.all([amount_spent, monthly_limit]).then do |(spent, limit)|
172			next unless spent >= limit || low_balance_and_not_auto_top_up
173
174			notify
175			LOG.info "Data warning #{customer.customer_id} #{@sim.iccid}"
176		end
177	end
178
179	def low_balance_and_not_auto_top_up
180		customer.balance < refill_price && !customer.auto_top_up_amount&.positive?
181	end
182end
183
184class SimAnnual
185	include SimAction
186
187	def notify
188		m = Blather::Stanza::Message.new
189		m.body = "Your SIM #{iccid} only has #{@sim.remaining_days} days left"
190		customer.stanza_to(m)
191	end
192
193	def annual_price
194		BigDecimal(CONFIG[:sims][customer.currency][:annual]) / 100
195	end
196
197	def call
198		if customer.balance >= annual_fee
199			refill_and_bill(1024, annual_price, "Annual fee for #{iccid}")
200		else
201			LowBalance.for(customer, refill_price).then(&:notify!).then do |result|
202				next call if result.positive?
203
204				notify
205			end
206		end
207	end
208
209	def annual_fee
210		customer.currency == :USD ? BigDecimal("5.50") : BigDecimal("7.50")
211	end
212end
213
214def fetch_customers(cids)
215	# This is gross N+1 against the DB, but also does a buch of Redis work
216	# We expect the set to be very small for the forseeable future,
217	# hundreds at most
218	EMPromise.all(
219		Set.new(cids).to_a.compact.map { |id|
220			CUSTOMER_REPO.find(id).catch_only(CustomerRepo::NotFound) { nil }
221		}
222	).then do |customers|
223		Hash[customers.map { |c| [c.customer_id, JobCustomer.new(c)] }]
224	end
225end
226
227SIM_QUERY = "SELECT iccid, customer_id FROM sims WHERE iccid = ANY ($1)"
228def load_customers!(sims)
229	DB.query_defer(SIM_QUERY, [sims.keys]).then { |rows|
230		fetch_customers(rows.map { |row| row["customer_id"] }).then do |customers|
231			rows.each do |row|
232				sims[row["iccid"]]&.customer = customers[row["customer_id"]]
233			end
234
235			sims
236		end
237	}
238end
239
240def decide_sim_actions(sims)
241	sims.each_with_object({}) { |sim, h|
242		if sim.remaining_days < 31
243			h[sim.iccid] = SimAnnual.new(sim)
244		elsif sim.remaining_usage_kb < 100000
245			h[sim.iccid] = SimTopUp.new(sim)
246		elsif sim.remaining_usage_kb < 250000
247			h[sim.iccid] = SimWarn.new(sim)
248		end
249	}.compact
250end
251
252EM.run do
253	REDIS = EM::Hiredis.connect
254	DB = Postgres.connect(dbname: "jmp")
255
256	BlatherNotify.start(
257		CONFIG[:xmpp][:jid],
258		CONFIG[:xmpp][:password]
259	).then { SIM_REPO.all }.then { |sims|
260		load_customers!(decide_sim_actions(sims))
261	}.then { |items|
262		items = items.values.select(&:customer)
263		EMPromise.all(items.map(&:call))
264	}.catch { |e|
265		LOG.error e
266
267		if e.is_a?(::Exception)
268			Sentry.capture_exception(e)
269		else
270			Sentry.capture_message(e.to_s)
271		end
272	}.then { EM.stop }
273end