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