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