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 return unless rates&.first&.dig("total")
407
408 total = BigDecimal(rates.first["total"], 2)
409 total *= 0.75 if params["currency"] == "USD"
410 params["country-name"] == "US" ? total.round : total.ceil
411 end
412
413 route do |r|
414 r.on "electrum_notify" do
415 verify_address_customer_id(r)
416
417 UnknownTransactions.from(
418 electrum.currency,
419 params["customer_id"],
420 params["address"],
421 electrum
422 .getaddresshistory(params["address"])
423 .map { |item| item["tx_hash"] }
424 ).enqueue!
425
426 "OK"
427 end
428
429 r.assets
430
431 atfd = r.cookies["atfd"] || SecureRandom.uuid
432 one_year = 60 * 60 * 24 * 365
433 response.set_cookie(
434 "atfd",
435 value: atfd, expires: Time.now + one_year
436 )
437 params.delete("atfd") if params["atfd"].to_s == ""
438 antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
439
440 r.on "esim-adapter" do
441 r.get "braintree" do
442 gateway = CreditCardGateway.new(params["currency"], antifrauds)
443 if gateway.antifraud
444 next view(
445 :message,
446 locals: { message: "Please contact support" }
447 )
448 end
449 render :braintree, locals: { token: gateway.client_token }
450 end
451
452 r.get "total" do
453 next "" unless params["postal-code"].to_s != ""
454
455 resp = get_rates
456 next resp["errors"].to_a.join("<br />") unless resp["success"]
457
458 render :total, locals: resp.merge("body" => stallion_shipment)
459 end
460
461 r.post do
462 gateway = CreditCardGateway.new(params["currency"], antifrauds)
463 if gateway.antifraud
464 next view(
465 :message,
466 locals: { message: "Please contact support" }
467 )
468 end
469
470 cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
471 rate = retail_rate(get_rates["rates"])
472
473 unless BigDecimal(params["amount"], 2) >= (cost + rate)
474 raise "Invalid amount"
475 end
476
477 sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
478
479 postal_lookup = JSON.parse(Net::HTTP.get(URI(
480 "https://app.zipcodebase.com/api/v1/search?" \
481 "apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
482 "codes=#{params['postal-code']}&" \
483 "country=#{params['country-name']}"
484 )))
485
486 uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
487 Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
488 req = Net::HTTP::Post.new(uri)
489 req["Content-Type"] = "application/json"
490 req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
491 body = stallion_shipment
492 body.merge!(body.delete(:to_address))
493 body[:store_id] = ENV.fetch("STALLION_STORE_ID")
494 body[:value] = BigDecimal(params["amount"], 2)
495 body[:currency] = params["currency"]
496 body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
497 body[:order_id] = sale_result.transaction.id
498 body[:name] = "Customer"
499 body[:city] = postal_lookup["results"].values.first.first["city"]
500 body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
501 req.body = body.to_json
502 resp = JSON.parse(http.request(req).read_body)
503 view :receipt, locals: resp.merge("rate" => rate)
504 end
505 rescue CreditCardGateway::ErrorResult
506 response.status = 400
507 $!.message
508 end
509
510 r.get do
511 view :esim_adapter, locals: { antifraud: atfd }
512 end
513 end
514
515 r.on :jid do |jid|
516 Sentry.set_user(id: params["customer_id"], jid: jid)
517
518 customer_id = params["customer_id"]
519 gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
520 topup = AutoTopUpRepo.new
521
522 r.on "credit_cards" do
523 r.get do
524 if gateway.antifraud
525 return view(
526 :message,
527 locals: { message: "Please contact support" }
528 )
529 end
530
531 view(
532 "credit_cards",
533 locals: {
534 jid: jid,
535 token: gateway.client_token,
536 customer_id: gateway.customer_id,
537 antifraud: atfd,
538 auto_top_up: topup.find(gateway.customer_id) ||
539 (gateway.payment_methods? ? "" : "15")
540 }
541 )
542 end
543
544 r.post do
545 CardVault
546 .for(
547 gateway, params["braintree_nonce"],
548 params["amount"].to_d
549 ).call(params["auto_top_up_amount"].to_i)
550 "OK"
551 rescue ThreeDSecureRepo::Failed
552 gateway.remove_method($!.message)
553 response.status = 400
554 "hostedFieldsFieldsInvalidError"
555 rescue CreditCardGateway::ErrorResult
556 response.status = 400
557 $!.message
558 end
559 end
560 end
561 end
562end
563
564run JmpPay.freeze.app