# frozen_string_literal: true
require "braintree"
require "date"
require "delegate"
require "dhall"
require "forwardable"
require "pg"
require "redis"
require "roda"
require "uri"
if ENV["RACK_ENV"] == "development"
require "pry-rescue"
use PryRescue::Rack
end
require_relative "lib/electrum"
require_relative "lib/transaction"
require "sentry-ruby"
Sentry.init do |config|
config.traces_sample_rate = 1
end
use Sentry::Rack::CaptureExceptions
REDIS = Redis.new
PLANS = Dhall.load("env:PLANS").sync
BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
ELECTRUM = Electrum.new(
**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
)
DB = PG.connect(dbname: "jmp")
DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
class Plan
def self.for(plan_name)
new(PLANS.find { |p| p[:name].to_s == plan_name })
end
def initialize(plan)
@plan = plan
end
def price(months=1)
(BigDecimal(@plan[:monthly_price].to_i) * months) / 10000
end
def currency
@plan[:currency].to_s.to_sym
end
def merchant_account
BRAINTREE_CONFIG[:merchant_accounts][currency]
end
def self.active?(customer_id)
DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i.positive?
SELECT count(1) AS count FROM customer_plans
WHERE customer_id=$1 AND expires_at > NOW()
SQL
end
def bill_plan(customer_id)
DB.transaction do
charge_for_plan(customer_id)
unless activate_plan_starting_now(customer_id)
add_one_month_to_current_plan(customer_id)
end
end
true
end
def activate_plan_starting_now(customer_id)
DB.exec(<<~SQL, [customer_id, @plan[:name]]).cmd_tuples.positive?
INSERT INTO plan_log
(customer_id, plan_name, date_range)
VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month'))
ON CONFLICT DO NOTHING
SQL
end
protected
def charge_for_plan(customer_id)
params = [
customer_id,
"#{customer_id}-bill-#{@plan[:name]}-at-#{Time.now.to_i}",
-price
]
DB.exec(<<~SQL, params)
INSERT INTO transactions
(customer_id, transaction_id, created_at, amount)
VALUES ($1, $2, LOCALTIMESTAMP, $3)
SQL
end
def add_one_month_to_current_plan(customer_id)
DB.exec(<<~SQL, [customer_id])
UPDATE plan_log SET date_range=range_merge(
date_range,
tsrange(
LOCALTIMESTAMP,
GREATEST(upper(date_range), LOCALTIMESTAMP) + '1 month'
)
)
WHERE
customer_id=$1 AND
date_range && tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month')
SQL
end
end
class CreditCardGateway
def initialize(jid, customer_id=nil)
@jid = jid
@customer_id = customer_id
@gateway = Braintree::Gateway.new(
environment: BRAINTREE_CONFIG[:environment].to_s,
merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
public_key: BRAINTREE_CONFIG[:public_key].to_s,
private_key: BRAINTREE_CONFIG[:private_key].to_s
)
end
def check_customer_id(cid)
return cid unless ENV["RACK_ENV"] == "production"
raise "customer_id does not match" unless @customer_id == cid
cid
end
def customer_id
customer_id = REDIS.get(redis_key_jid)
return customer_id if check_customer_id(customer_id)
result = @gateway.customer.create
raise "Braintree customer create failed" unless result.success?
@customer_id = result.customer.id
save_customer_id!
end
def save_customer_id!
unless REDIS.set(redis_key_jid, @customer_id) == "OK"
raise "Saving new jid,customer to redis failed"
end
unless REDIS.set(redis_key_customer_id, @jid) == "OK"
raise "Saving new customer,jid to redis failed"
end
@customer_id
end
def client_token
@gateway.client_token.generate(customer_id: customer_id)
end
def payment_methods?
!@gateway.customer.find(customer_id).payment_methods.empty?
end
def default_payment_method=(nonce)
@gateway.payment_method.create(
customer_id: customer_id,
payment_method_nonce: nonce,
options: {
make_default: true
}
)
end
def decline_guard(ip)
customer_declines, ip_declines = REDIS.mget(
"jmp_pay_decline-#{@customer_id}",
"jmp_pay_decline-#{ip}"
)
customer_declines.to_i < 2 && ip_declines.to_i < 4
end
def sale(ip:, **kwargs)
return nil unless decline_guard(ip)
tx = Transaction.sale(@gateway, **kwargs)
return tx if tx
REDIS.incr("jmp_pay_decline-#{@customer_id}")
REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
REDIS.incr("jmp_pay_decline-#{ip}")
REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
nil
end
def buy_plan(plan_name, nonce, ip)
plan = Plan.for(plan_name)
sale(
ip: ip,
amount: plan.price(5),
payment_method_nonce: nonce,
merchant_account_id: plan.merchant_account,
options: { submit_for_settlement: true }
)&.insert && plan.bill_plan(@customer_id)
end
protected
def redis_key_jid
"jmp_customer_id-#{@jid}"
end
def redis_key_customer_id
"jmp_customer_jid-#{@customer_id}"
end
end
class UnknownTransactions
def self.from(customer_id, address, tx_hashes)
self.for(
customer_id,
fetch_rows_for(address, tx_hashes).map { |row|
row["transaction_id"]
}
)
end
def self.fetch_rows_for(address, tx_hashes)
values = tx_hashes.map { |tx_hash|
"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
}
return [] if values.empty?
DB.exec_params(<<-SQL)
SELECT transaction_id FROM
(VALUES #{values.join(',')}) AS t(transaction_id)
LEFT JOIN transactions USING (transaction_id)
WHERE transactions.transaction_id IS NULL
SQL
end
def self.for(customer_id, transaction_ids)
transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
end
def initialize(customer_id, transaction_ids)
@customer_id = customer_id
@transaction_ids = transaction_ids
end
def enqueue!
REDIS.hset(
"pending_btc_transactions",
*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
)
end
class None
def enqueue!; end
end
end
class JmpPay < Roda
SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
plugin :render, engine: "slim"
plugin :common_logger, $stdout
extend Forwardable
def_delegators :request, :params
def redis_key_btc_addresses
"jmp_customer_btc_addresses-#{params['customer_id']}"
end
def verify_address_customer_id(r)
return if REDIS.sismember(redis_key_btc_addresses, params["address"])
warn "Address and customer_id do not match"
r.halt([
403,
{ "Content-Type" => "text/plain" },
"Address and customer_id do not match"
])
end
route do |r|
r.on "electrum_notify" do
verify_address_customer_id(r)
UnknownTransactions.from(
params["customer_id"],
params["address"],
ELECTRUM
.getaddresshistory(params["address"])
.map { |item| item["tx_hash"] }
).enqueue!
"OK"
end
r.on :jid do |jid|
Sentry.set_user(id: params["customer_id"], jid: jid)
gateway = CreditCardGateway.new(jid, params["customer_id"])
topup = "jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
r.on "activate" do
Sentry.configure_scope do |scope|
scope.set_transaction_name("activate")
scope.set_context(
"activate",
plan_name: params["plan_name"]
)
end
render = lambda do |l={}|
view(
"activate",
locals: {
token: gateway.client_token,
customer_id: gateway.customer_id,
error: false
}.merge(l)
)
end
r.get do
if Plan.active?(gateway.customer_id)
r.redirect params["return_to"], 303
else
render.call
end
end
r.post do
result = DB.transaction {
Plan.active?(gateway.customer_id) || gateway.buy_plan(
params["plan_name"],
params["braintree_nonce"],
request.ip
)
}
if params["auto_top_up_amount"].to_i >= 15
REDIS.set(topup, params["auto_top_up_amount"].to_i)
end
if result
r.redirect params["return_to"], 303
else
render.call(error: true)
end
end
end
r.on "credit_cards" do
r.get do
view(
"credit_cards",
locals: {
token: gateway.client_token,
customer_id: gateway.customer_id,
auto_top_up: REDIS.get(topup) ||
(gateway.payment_methods? ? "" : "15")
}
)
end
r.post do
gateway.default_payment_method = params["braintree_nonce"]
if params["auto_top_up_amount"].to_i >= 15
REDIS.set(topup, params["auto_top_up_amount"].to_i)
elsif params["auto_top_up_amount"].to_i.zero?
REDIS.del(topup)
end
"OK"
end
end
end
end
end
run JmpPay.freeze.app