1#!/usr/bin/ruby
2# frozen_string_literal: true
3
4# Usage: bin/process_pending-btc_transactions '{
5# healthchecks_url = "https://hc-ping.com/...",
6# oxr_app_id = "",
7# required_confirmations = 3,
8# notify_using = {
9# jid = "",
10# password = "",
11# target = \(jid: Text) -> "+12266669977@cheogram.com",
12# body = \(jid: Text) -> \(body: Text) -> "/msg ${jid} ${body}",
13# },
14# electrum = env:ELECTRUM_CONFIG,
15# plans = ./plans.dhall,
16# activation_amount = 10000
17# }'
18
19require "bigdecimal"
20require "dhall"
21require "money/bank/open_exchange_rates_bank"
22require "net/http"
23require "nokogiri"
24require "pg"
25require "redis"
26
27require_relative "../lib/blather_notify"
28require_relative "../lib/electrum"
29
30CONFIG =
31 Dhall::Coder
32 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
33 .load(ARGV[0], transform_keys: :to_sym)
34
35Net::HTTP.post(URI("#{CONFIG[:healthchecks_url]}/start"), "")
36
37REDIS = Redis.new
38ELECTRUM = Electrum.new(**CONFIG[:electrum])
39
40DB = PG.connect(dbname: "jmp")
41DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
42DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
43
44BlatherNotify.start(
45 CONFIG[:notify_using][:jid],
46 CONFIG[:notify_using][:password]
47)
48
49unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
50 oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
51 oxr.app_id = CONFIG.fetch(:oxr_app_id)
52 oxr.update_rates
53 cad_to_usd = oxr.get_rate("CAD", "USD")
54 REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
55end
56
57canadianbitcoins = Nokogiri::HTML.parse(
58 Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
59)
60
61bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
62raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
63
64btc_sell_price = {}
65btc_sell_price[:CAD] = BigDecimal.new(
66 bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
67)
68btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
69
70class Plan
71 def self.for_customer(customer)
72 row = DB.exec_params(<<-SQL, [customer.id]).first
73 SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
74 SQL
75 from_name(customer, row&.[]("plan_name"))
76 end
77
78 def self.pending_for_customer(customer)
79 from_name(
80 customer,
81 REDIS.get("pending_plan_for-#{customer.id}"),
82 klass: Pending
83 )
84 end
85
86 def self.from_name(customer, plan_name, klass: Plan)
87 return unless plan_name
88 plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
89 klass.new(customer, plan) if plan
90 end
91
92 def initialize(customer, plan)
93 @customer = customer
94 @plan = plan
95 end
96
97 def name
98 @plan[:name]
99 end
100
101 def currency
102 @plan[:currency]
103 end
104
105 def bonus_for(fiat_amount)
106 return BigDecimal.new(0) if fiat_amount <= 15
107 fiat_amount * case fiat_amount
108 when (15..29.99)
109 0.01
110 when (30..139.99)
111 0.03
112 else
113 0.05
114 end
115 end
116
117 def price
118 BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001
119 end
120
121 def insert(start:, expire:)
122 params = [@customer.id, name, start, expire]
123 DB.exec_params(<<-SQL, params)
124 INSERT INTO plan_log
125 (customer_id, plan_name, date_range)
126 VALUES
127 ($1, $2, tsrange($3, $4))
128 SQL
129 end
130
131 def activate_any_pending_plan!; end
132
133 class Pending < Plan
134 def initialize(customer, plan)
135 super
136 @go_until = Date.today >> 1
137 end
138
139 def activation_amount
140 camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001
141 [camnt, price].max
142 end
143
144 def activate_any_pending_plan!
145 if @customer.balance < activation_amount
146 @customer.notify(
147 "Your account could not be activated because your " \
148 "balance of $#{@customer.balance} is less that the " \
149 "required activation amount of $#{activation_amount}. " \
150 "Please buy more credit to have your account activated."
151 )
152 else
153 charge_insert_notify
154 end
155 end
156
157 protected
158
159 def charge_insert_notify
160 @customer.add_transaction(
161 "activate_#{name}_until_#{@go_until}",
162 -price,
163 "Activate pending plan"
164 )
165 insert(start: Date.today, expire: @go_until)
166 REDIS.del("pending_plan_for-#{@customer.id}")
167 notify_approved
168 end
169
170 def notify_approved
171 sid = REDIS.get("reg-sid_for-#{@customer.id}")
172 tel = REDIS.get("reg-session_tel-#{sid}")&.sub(/\+/, "%2B")
173 @customer.notify(
174 "Your JMP account has been approved. To complete " \
175 "your signup, click/tap here: " \
176 "https://jmp.chat/sp1a/register4/?sid=#{sid}&number=#{tel}"
177 )
178 end
179 end
180end
181
182class Customer
183 def initialize(customer_id)
184 @customer_id = customer_id
185 end
186
187 def id
188 @customer_id
189 end
190
191 def notify(body)
192 jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
193 raise "No JID for #{customer_id}" unless jid
194 BlatherNotify.say(
195 CONFIG[:notify_using][:target].call(jid),
196 CONFIG[:notify_using][:body].call(jid, body)
197 )
198 end
199
200 def plan
201 Plan.for_customer(self) || pending_plan
202 end
203
204 def pending_plan
205 Plan.pending_for_customer(self)
206 end
207
208 def balance
209 result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
210 SELECT balance FROM balances WHERE customer_id=$1
211 SQL
212 result || BigDecimal.new(0)
213 end
214
215 def add_btc_credit(txid, btc_amount, fiat_amount)
216 return unless add_transaction(txid, fiat_amount, "Bitcoin payment")
217 if (bonus = plan.bonus_for(fiat_amount)) > 0
218 add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
219 end
220 notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
221 end
222
223 def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
224 tx_hash, = txid.split("/", 2)
225 notify([
226 "Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ",
227 "has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
228 ("+ $#{'%.4f' % bonus} bonus " if bonus > 0),
229 "to your account.\n(txhash: #{tx_hash})"
230 ].compact.join)
231 end
232
233 def add_transaction(id, amount, note)
234 DB.exec_params(<<-SQL, [@customer_id, id, amount, note]).cmd_tuples > 0
235 INSERT INTO transactions
236 (customer_id, transaction_id, amount, note)
237 VALUES
238 ($1, $2, $3, $4)
239 ON CONFLICT (transaction_id) DO NOTHING
240 SQL
241 end
242end
243
244done = REDIS.hgetall("pending_btc_transactions").map do |(txid, customer_id)|
245 tx_hash, address = txid.split("/", 2)
246 transaction = ELECTRUM.gettransaction(tx_hash)
247 next unless transaction.confirmations >= CONFIG[:required_confirmations]
248 btc = transaction.amount_for(address)
249 if btc <= 0
250 warn "Transaction shows as #{btc}, skipping #{txid}"
251 next
252 end
253 DB.transaction do
254 customer = Customer.new(customer_id)
255 if (plan = customer.plan)
256 amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
257 customer.add_btc_credit(txid, btc, amount)
258 customer.plan.activate_any_pending_plan!
259 REDIS.hdel("pending_btc_transactions", txid)
260 txid
261 else
262 warn "No plan for #{customer_id} cannot save #{txid}"
263 end
264 end
265end
266
267Net::HTTP.post(URI(CONFIG[:healthchecks_url].to_s), done.compact.join("\n"))