1# frozen_string_literal: true
2
3require "braintree"
4require "bigdecimal/util"
5require "countries"
6require "date"
7require "delegate"
8require "dhall"
9require "forwardable"
10require "pg"
11require "redis"
12require "roda"
13require "uri"
14require "json"
15
16if ENV["RACK_ENV"] == "development"
17 require "pry-rescue"
18 use PryRescue::Rack
19end
20
21require_relative "lib/auto_top_up_repo"
22require_relative "lib/customer"
23require_relative "lib/three_d_secure_repo"
24require_relative "lib/electrum"
25require_relative "lib/transaction"
26
27require "sentry-ruby"
28Sentry.init do |config|
29 config.traces_sample_rate = 0.01
30end
31use Sentry::Rack::CaptureExceptions
32
33REDIS = Redis.new
34PLANS = Dhall.load("env:PLANS").sync
35BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
36ELECTRUM = Electrum.new(
37 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
38)
39ELECTRUM_BCH = Electrum.new(
40 **Dhall::Coder.load("env:ELECTRON_CASH_CONFIG", transform_keys: :to_sym)
41)
42
43DB = PG.connect(dbname: "jmp")
44DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
45DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
46
47class CreditCardGateway
48 class ErrorResult < StandardError
49 def self.for(result)
50 if result.verification&.status == "gateway_rejected" &&
51 result.verification&.gateway_rejection_reason == "cvv"
52 new("fieldInvalidForCvv")
53 else
54 new(result.message)
55 end
56 end
57 end
58
59 def initialize(currency, antifraud)
60 @currency = currency
61 @antifraud = antifraud
62
63 @gateway = Braintree::Gateway.new(
64 environment: BRAINTREE_CONFIG[:environment].to_s,
65 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
66 public_key: BRAINTREE_CONFIG[:public_key].to_s,
67 private_key: BRAINTREE_CONFIG[:private_key].to_s
68 )
69 end
70
71 def merchant_account
72 BRAINTREE_CONFIG[:merchant_accounts][@currency]
73 end
74
75 def client_token(**kwargs)
76 kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
77 @gateway.client_token.generate(**kwargs)
78 end
79
80 def antifraud
81 REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti|
82 anti.to_i > 2
83 end &&
84 Braintree::ErrorResult.new(
85 @gateway, errors: {}, message: "Please contact support"
86 )
87 end
88
89 def with_antifraud
90 result = antifraud || yield
91 return result if result.success?
92
93 @antifraud.each do |k|
94 REDIS.incr("jmp_antifraud-#{k}")
95 REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
96 end
97
98 raise ErrorResult.for(result)
99 end
100
101 def sale(nonce, amount, **kwargs)
102 with_antifraud do
103 @gateway.transaction.sale(
104 payment_method_nonce: nonce,
105 amount: amount, merchant_account_id: merchant_account.to_s,
106 options: {
107 store_in_vault_on_success: true, submit_for_settlement: true
108 }, **kwargs
109 )
110 end
111 end
112
113 def customer
114 @gateway.customer
115 end
116
117 def payment_method
118 @gateway.payment_method
119 end
120end
121
122class CreditCardCustomerGateway
123 def initialize(jid, customer_id, antifraud)
124 @jid = jid
125 @customer_id = customer_id
126 @gateway = CreditCardGateway.new(
127 customer_plan&.dig(:currency),
128 antifraud
129 )
130 end
131
132 def merchant_account
133 @gateway.merchant_account
134 end
135
136 def check_customer_id(cid)
137 return cid unless ENV["RACK_ENV"] == "production"
138
139 raise "customer_id does not match" unless @customer_id == cid
140
141 cid
142 end
143
144 def customer_id
145 customer_id = Customer.new(nil, @jid).customer_id
146 return customer_id if check_customer_id(customer_id)
147
148 result = @gateway.customer.create
149 raise "Braintree customer create failed" unless result.success?
150
151 @customer_id = result.customer.id
152 Customer.new(@customer_id, @jid).save!
153 @customer_id
154 end
155
156 def customer_plan
157 name = DB.exec_params(<<~SQL, [customer_id]).first&.[]("plan_name")
158 SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
159 SQL
160 PLANS.find { |plan| plan[:name].to_s == name }
161 end
162
163 def client_token
164 @gateway.client_token(customer_id: customer_id)
165 end
166
167 def payment_methods?
168 !@gateway.customer.find(customer_id).payment_methods.empty?
169 end
170
171 def antifraud
172 return if REDIS.exists?("jmp_antifraud_bypass-#{customer_id}")
173
174 @gateway.antifraud
175 end
176
177 def with_antifraud(&blk)
178 @gateway.with_antifraud(&blk)
179 end
180
181 def sale(nonce, amount)
182 @gateway.sale(nonce, amount, customer_id: customer_id)
183 end
184
185 def default_method(nonce)
186 with_antifraud do
187 @gateway.payment_method.create(
188 customer_id: customer_id, payment_method_nonce: nonce,
189 options: {
190 verify_card: true, make_default: true,
191 verification_merchant_account_id: merchant_account.to_s
192 }
193 )
194 end
195 end
196
197 def remove_method(token)
198 @gateway.payment_method.delete(token)
199 end
200end
201
202class UnknownTransactions
203 def self.from(currency, customer_id, address, tx_hashes)
204 self.for(
205 currency,
206 customer_id,
207 fetch_rows_for(address, tx_hashes).map { |row|
208 row["transaction_id"]
209 }
210 )
211 end
212
213 def self.fetch_rows_for(address, tx_hashes)
214 values = tx_hashes.map { |tx_hash|
215 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
216 }
217 return [] if values.empty?
218
219 DB.exec_params(<<-SQL)
220 SELECT transaction_id FROM
221 (VALUES #{values.join(',')}) AS t(transaction_id)
222 LEFT JOIN transactions USING (transaction_id)
223 WHERE transactions.transaction_id IS NULL
224 SQL
225 end
226
227 def self.for(currency, customer_id, transaction_ids)
228 return None.new if transaction_ids.empty?
229
230 new(currency, customer_id, transaction_ids)
231 end
232
233 def initialize(currency, customer_id, transaction_ids)
234 @currency = currency
235 @customer_id = customer_id
236 @transaction_ids = transaction_ids
237 end
238
239 def enqueue!
240 REDIS.hset(
241 "pending_#{@currency}_transactions",
242 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
243 )
244 end
245
246 class None
247 def enqueue!; end
248 end
249end
250
251class CardVault
252 def self.for(gateway, nonce, amount=nil)
253 if amount&.positive?
254 CardDeposit.new(gateway, nonce, amount)
255 else
256 new(gateway, nonce)
257 end
258 end
259
260 def initialize(gateway, nonce)
261 @gateway = gateway
262 @nonce = nonce
263 end
264
265 def call(auto_top_up_amount)
266 result = vault!
267 ThreeDSecureRepo.new.put_from_result(result)
268 AutoTopUpRepo.new.put(
269 @gateway.customer_id,
270 auto_top_up_amount
271 )
272 result
273 end
274
275 def vault!
276 @gateway.default_method(@nonce)
277 end
278
279 class CardDeposit < self
280 def initialize(gateway, nonce, amount)
281 super(gateway, nonce)
282 @amount = amount
283
284 return unless @amount < 15 || @amount > 35
285
286 raise CreditCardGateway::ErrorResult, "amount too low or too high"
287 end
288
289 def call(*)
290 result = super
291 Transaction.new(
292 @gateway.customer_id,
293 result.transaction.id,
294 @amount,
295 "Credit card payment"
296 ).save
297 result
298 end
299
300 def vault!
301 @gateway.sale(@nonce, @amount)
302 end
303 end
304end
305
306class JmpPay < Roda
307 SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
308 plugin :render, engine: "slim"
309 plugin :cookies, path: "/"
310 plugin :common_logger, $stdout
311 plugin :content_for
312 plugin(
313 :assets,
314 css: { tom_select: "tom_select.scss", loader: "loader.scss" },
315 js: {
316 section_list: "section_list.js",
317 tom_select: "tom_select.js",
318 htmx: "htmx.js"
319 },
320 add_suffix: true
321 )
322
323 extend Forwardable
324 def_delegators :request, :params
325
326 def electrum
327 case params["currency"]
328 when "bch"
329 ELECTRUM_BCH
330 else
331 ELECTRUM
332 end
333 end
334
335 def redis_key_btc_addresses
336 "jmp_customer_#{electrum.currency}_addresses-#{params['customer_id']}"
337 end
338
339 def verify_address_customer_id(r)
340 return if REDIS.sismember(redis_key_btc_addresses, params["address"])
341
342 warn "Address and customer_id do not match"
343 r.halt([
344 403,
345 { "Content-Type" => "text/plain" },
346 "Address and customer_id do not match"
347 ])
348 end
349
350 def nil_empty(s)
351 s.to_s == "" ? nil : s
352 end
353
354 def stallion_shipment
355 {
356 to_address: {
357 country_code: params["country-name"],
358 postal_code: params["postal-code"],
359 address1: nil_empty(params["street-address"]),
360 email: nil_empty(params["email"])
361 }.compact,
362 weight_unit: "g",
363 weight:
364 (params["esim_adapter_quantity"].to_i * 10) +
365 (params["pcsc_quantity"].to_i * 30),
366 size_unit: "in",
367 length: 18,
368 width: 8.5,
369 height: params["pcsc_quantity"].to_i.positive? ? 1 : 0.1,
370 package_type: "Large Envelope Or Flat",
371 items: [
372 {
373 title: "Memory Card",
374 description: "Memory Card",
375 quantity: params["esim_adapter_quantity"].to_i,
376 value: params["currency"] == "CAD" ? 54.99 : 39.99,
377 currency: params["currency"],
378 country_of_origin: "CN"
379 },
380 {
381 title: "Card Reader",
382 description: "Card Reader",
383 quantity: params["pcsc_quantity"].to_i,
384 value: params["currency"] == "CAD" ? 13.50 : 10,
385 currency: params["currency"],
386 country_of_origin: "CN"
387 }
388 ].select { |item| item[:quantity].positive? },
389 insured: true
390 }
391 end
392
393 def get_rates
394 uri = URI("https://ship.stallionexpress.ca/api/v4/rates")
395 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
396 req = Net::HTTP::Post.new(uri)
397 req["Content-Type"] = "application/json"
398 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
399 body = stallion_shipment
400 req.body = body.to_json
401 JSON.parse(http.request(req).read_body)
402 end
403 end
404
405 def retail_rate(rates)
406 total = BigDecimal(rates.first["total"], 2)
407 total *= 0.75 if params["currency"] == "USD"
408 params["country-name"] == "US" ? total.round : total.ceil
409 end
410
411 route do |r|
412 r.on "electrum_notify" do
413 verify_address_customer_id(r)
414
415 UnknownTransactions.from(
416 electrum.currency,
417 params["customer_id"],
418 params["address"],
419 electrum
420 .getaddresshistory(params["address"])
421 .map { |item| item["tx_hash"] }
422 ).enqueue!
423
424 "OK"
425 end
426
427 r.assets
428
429 atfd = r.cookies["atfd"] || SecureRandom.uuid
430 one_year = 60 * 60 * 24 * 365
431 response.set_cookie(
432 "atfd",
433 value: atfd, expires: Time.now + one_year
434 )
435 params.delete("atfd") if params["atfd"].to_s == ""
436 antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
437
438 r.on "esim-adapter" do
439 r.get "braintree" do
440 gateway = CreditCardGateway.new(params["currency"], antifrauds)
441 if gateway.antifraud
442 next view(
443 :message,
444 locals: { message: "Please contact support" }
445 )
446 end
447 render :braintree, locals: { token: gateway.client_token }
448 end
449
450 r.get "total" do
451 next "" unless params["postal-code"].to_s != ""
452
453 resp = get_rates
454 next resp["errors"].to_a.join("<br />") unless resp["success"]
455
456 render :total, locals: resp.merge("body" => stallion_shipment)
457 end
458
459 r.post do
460 gateway = CreditCardGateway.new(params["currency"], antifrauds)
461 if gateway.antifraud
462 next view(
463 :message,
464 locals: { message: "Please contact support" }
465 )
466 end
467
468 cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
469 rate = retail_rate(get_rates["rates"])
470
471 unless BigDecimal(params["amount"], 2) >= (cost + rate)
472 raise "Invalid amount"
473 end
474
475 sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
476
477 postal_lookup = JSON.parse(Net::HTTP.get(URI(
478 "https://app.zipcodebase.com/api/v1/search?" \
479 "apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
480 "codes=#{params['postal-code']}&" \
481 "country=#{params['country-name']}"
482 )))
483
484 uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
485 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
486 req = Net::HTTP::Post.new(uri)
487 req["Content-Type"] = "application/json"
488 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
489 body = stallion_shipment
490 body.merge!(body.delete(:to_address))
491 body[:store_id] = ENV.fetch("STALLION_STORE_ID")
492 body[:value] = BigDecimal(params["amount"], 2)
493 body[:currency] = params["currency"]
494 body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
495 body[:order_id] = sale_result.transaction.id
496 body[:name] = "Customer"
497 body[:city] = postal_lookup["results"].values.first.first["city"]
498 body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
499 req.body = body.to_json
500 resp = JSON.parse(http.request(req).read_body)
501 view :receipt, locals: resp.merge("rate" => rate)
502 end
503 rescue CreditCardGateway::ErrorResult
504 response.status = 400
505 $!.message
506 end
507
508 r.get do
509 view :esim_adapter, locals: { antifraud: atfd }
510 end
511 end
512
513 r.on :jid do |jid|
514 Sentry.set_user(id: params["customer_id"], jid: jid)
515
516 customer_id = params["customer_id"]
517 gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
518 topup = AutoTopUpRepo.new
519
520 r.on "credit_cards" do
521 r.get do
522 if gateway.antifraud
523 return view(
524 :message,
525 locals: { message: "Please contact support" }
526 )
527 end
528
529 view(
530 "credit_cards",
531 locals: {
532 jid: jid,
533 token: gateway.client_token,
534 customer_id: gateway.customer_id,
535 antifraud: atfd,
536 auto_top_up: topup.find(gateway.customer_id) ||
537 (gateway.payment_methods? ? "" : "15")
538 }
539 )
540 end
541
542 r.post do
543 CardVault
544 .for(
545 gateway, params["braintree_nonce"],
546 params["amount"].to_d
547 ).call(params["auto_top_up_amount"].to_i)
548 "OK"
549 rescue ThreeDSecureRepo::Failed
550 gateway.remove_method($!.message)
551 response.status = 400
552 "hostedFieldsFieldsInvalidError"
553 rescue CreditCardGateway::ErrorResult
554 response.status = 400
555 $!.message
556 end
557 end
558 end
559 end
560end
561
562run JmpPay.freeze.app