1# frozen_string_literal: true
2
3require "braintree"
4require "date"
5require "delegate"
6require "dhall"
7require "pg"
8require "redis"
9require "roda"
10require "uri"
11
12if ENV["RACK_ENV"] == "development"
13 require "pry-rescue"
14 use PryRescue::Rack
15end
16
17require_relative "lib/electrum"
18
19require "sentry-ruby"
20Sentry.init do |config|
21 config.traces_sample_rate = 1
22end
23use Sentry::Rack::CaptureExceptions
24
25REDIS = Redis.new
26PLANS = Dhall.load("env:PLANS").sync
27BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
28ELECTRUM = Electrum.new(
29 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
30)
31
32DB = PG.connect(dbname: "jmp")
33DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
34DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
35
36class Plan
37 def self.for(plan_name)
38 new(PLANS.find { |p| p[:name].to_s == plan_name })
39 end
40
41 def initialize(plan)
42 @plan = plan
43 end
44
45 def price(months=1)
46 (BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
47 end
48
49 def currency
50 @plan[:currency].to_s.to_sym
51 end
52
53 def merchant_account
54 BRAINTREE_CONFIG[:merchant_accounts][currency]
55 end
56
57 def self.active?(customer_id)
58 DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0
59 SELECT count(1) AS count FROM customer_plans
60 WHERE customer_id=$1 AND expires_at > NOW()
61 SQL
62 end
63
64 def activate(customer_id, months)
65 DB.exec_params(
66 "INSERT INTO plan_log VALUES ($1, $2, tsrange($3, $4))",
67 [customer_id, @plan[:name], Time.now, Date.today >> months]
68 )
69 true
70 end
71end
72
73class CreditCardGateway
74 def initialize(jid, customer_id=nil)
75 @jid = jid
76 @customer_id = customer_id
77
78 @gateway = Braintree::Gateway.new(
79 environment: BRAINTREE_CONFIG[:environment].to_s,
80 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
81 public_key: BRAINTREE_CONFIG[:public_key].to_s,
82 private_key: BRAINTREE_CONFIG[:private_key].to_s
83 )
84 end
85
86 def check_customer_id(cid)
87 return cid unless ENV["RACK_ENV"] == "production"
88
89 raise "customer_id does not match" unless @customer_id == cid
90
91 cid
92 end
93
94 def customer_id
95 customer_id = REDIS.get(redis_key_jid)
96 return customer_id if check_customer_id(customer_id)
97
98 result = @gateway.customer.create
99 raise "Braintree customer create failed" unless result.success?
100 @customer_id = result.customer.id
101 save_customer_id!
102 end
103
104 def save_customer_id!
105 unless REDIS.set(redis_key_jid, @customer_id) == "OK"
106 raise "Saving new jid,customer to redis failed"
107 end
108
109 unless REDIS.set(redis_key_customer_id, @jid) == "OK"
110 raise "Saving new customer,jid to redis failed"
111 end
112
113 @customer_id
114 end
115
116 def client_token
117 @gateway.client_token.generate(customer_id: customer_id)
118 end
119
120 def default_payment_method=(nonce)
121 @gateway.payment_method.create(
122 customer_id: customer_id,
123 payment_method_nonce: nonce,
124 options: {
125 make_default: true
126 }
127 )
128 end
129
130 def decline_guard(ip)
131 customer_declines, ip_declines = REDIS.mget(
132 "jmp_pay_decline-#{@customer_id}",
133 "jmp_pay_decline-#{ip}"
134 )
135 customer_declines.to_i < 2 && ip_declines.to_i < 4
136 end
137
138 def sale(ip:, **kwargs)
139 return false unless decline_guard(ip)
140 result = @gateway.transaction.sale(**kwargs)
141 return true if result.success?
142
143 REDIS.incr("jmp_pay_decline-#{@customer_id}")
144 REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
145 REDIS.incr("jmp_pay_decline-#{ip}")
146 REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
147 false
148 end
149
150 def buy_plan(plan_name, months, nonce, ip)
151 plan = Plan.for(plan_name)
152 sale(
153 ip: ip,
154 amount: plan.price(months),
155 payment_method_nonce: nonce,
156 merchant_account_id: plan.merchant_account,
157 options: {submit_for_settlement: true}
158 ) && plan.activate(@customer_id, months)
159 end
160
161protected
162
163 def redis_key_jid
164 "jmp_customer_id-#{@jid}"
165 end
166
167 def redis_key_customer_id
168 "jmp_customer_jid-#{@customer_id}"
169 end
170end
171
172class UnknownTransactions
173 def self.from(customer_id, address, tx_hashes)
174 self.for(
175 customer_id,
176 fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
177 )
178 end
179
180 def self.fetch_rows_for(address, tx_hashes)
181 values = tx_hashes.map do |tx_hash|
182 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
183 end
184 return [] if values.empty?
185 DB.exec_params(<<-SQL)
186 SELECT transaction_id FROM
187 (VALUES #{values.join(',')}) AS t(transaction_id)
188 LEFT JOIN transactions USING (transaction_id)
189 WHERE transactions.transaction_id IS NULL
190 SQL
191 end
192
193 def self.for(customer_id, transaction_ids)
194 transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
195 end
196
197 def initialize(customer_id, transaction_ids)
198 @customer_id = customer_id
199 @transaction_ids = transaction_ids
200 end
201
202 def enqueue!
203 REDIS.hset(
204 "pending_btc_transactions",
205 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
206 )
207 end
208
209 class None
210 def enqueue!; end
211 end
212end
213
214class JmpPay < Roda
215 SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
216 plugin :render, engine: "slim"
217 plugin :common_logger, $stdout
218
219 def redis_key_btc_addresses
220 "jmp_customer_btc_addresses-#{request.params['customer_id']}"
221 end
222
223 def verify_address_customer_id(r)
224 return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
225
226 warn "Address and customer_id do not match"
227 r.halt([
228 403,
229 {"Content-Type" => "text/plain"},
230 "Address and customer_id do not match"
231 ])
232 end
233
234 route do |r|
235 r.on "electrum_notify" do
236 verify_address_customer_id(r)
237
238 UnknownTransactions.from(
239 request.params["customer_id"],
240 request.params["address"],
241 ELECTRUM
242 .getaddresshistory(request.params["address"])
243 .map { |item| item["tx_hash"] }
244 ).enqueue!
245
246 "OK"
247 end
248
249 r.on :jid do |jid|
250 Sentry.set_user(id: request.params["customer_id"], jid: jid)
251
252 gateway = CreditCardGateway.new(
253 jid,
254 request.params["customer_id"]
255 )
256
257 r.on "activate" do
258 Sentry.configure_scope do |scope|
259 scope.set_transaction_name("activate")
260 scope.set_context(
261 "activate",
262 plan_name: request.params["plan_name"]
263 )
264 end
265
266 render = lambda do |l={}|
267 view(
268 "activate",
269 locals: {
270 token: gateway.client_token,
271 customer_id: gateway.customer_id,
272 error: false
273 }.merge(l)
274 )
275 end
276
277 r.get do
278 if Plan.active?(gateway.customer_id)
279 r.redirect request.params["return_to"], 303
280 else
281 render.call
282 end
283 end
284
285 r.post do
286 result = DB.transaction do
287 Plan.active?(gateway.customer_id) || gateway.buy_plan(
288 request.params["plan_name"],
289 5,
290 request.params["braintree_nonce"],
291 request.ip
292 )
293 end
294 if request.params["auto_top_up_amount"].to_i >= 15
295 REDIS.set(
296 "jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
297 request.params["auto_top_up_amount"].to_i
298 )
299 end
300 if result
301 r.redirect request.params["return_to"], 303
302 else
303 render.call(error: true)
304 end
305 end
306 end
307
308 r.on "credit_cards" do
309 r.get do
310 view(
311 "credit_cards",
312 locals: {
313 token: gateway.client_token,
314 customer_id: gateway.customer_id
315 }
316 )
317 end
318
319 r.post do
320 gateway.default_payment_method = request.params["braintree_nonce"]
321 "OK"
322 end
323 end
324 end
325 end
326end
327
328run JmpPay.freeze.app