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