From 1eb15da7c508f3f3a4c4b3c921d74b86c78feea0 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 15 May 2022 00:39:31 -0500 Subject: [PATCH] upgrades: add authorize.net integration. Add integration for accepting payments with Authorize.net. https://developer.authorize.net/hello_world.html --- app/controllers/user_upgrades_controller.rb | 2 +- app/controllers/webhooks_controller.rb | 8 +- app/helpers/user_upgrades_helper.rb | 7 - app/logical/authorize_net_client.rb | 148 ++++++++++++++++++ .../payment_transaction/authorize_net.rb | 125 +++++++++++++++ app/models/user_upgrade.rb | 23 ++- app/views/user_upgrades/create.js.erb | 17 +- app/views/user_upgrades/new.html.erb | 4 +- app/views/users/_upgrade_notice.html.erb | 2 +- config/danbooru_default_config.rb | 20 +++ config/routes.rb | 1 + 11 files changed, 340 insertions(+), 17 deletions(-) delete mode 100644 app/helpers/user_upgrades_helper.rb create mode 100644 app/logical/authorize_net_client.rb create mode 100644 app/logical/payment_transaction/authorize_net.rb diff --git a/app/controllers/user_upgrades_controller.rb b/app/controllers/user_upgrades_controller.rb index e3363ce5d..ab28ec58b 100644 --- a/app/controllers/user_upgrades_controller.rb +++ b/app/controllers/user_upgrades_controller.rb @@ -4,7 +4,7 @@ class UserUpgradesController < ApplicationController respond_to :js, :html, :json, :xml def create - @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type]) + @user_upgrade = authorize UserUpgrade.create(recipient: recipient, purchaser: CurrentUser.user, status: "pending", upgrade_type: params[:upgrade_type], payment_processor: params[:payment_processor]) @country = params[:country] || CurrentUser.country || "US" @allow_promotion_codes = params[:promo].to_s.truthy? @checkout = @user_upgrade.create_checkout!(country: @country, allow_promotion_codes: @allow_promotion_codes) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index fef02abb2..8f6ded08c 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class WebhooksController < ApplicationController - skip_forgery_protection only: :receive + skip_forgery_protection only: [:receive, :authorize_net] + rescue_with Stripe::SignatureVerificationError, status: 400 rescue_with DiscordSlashCommand::WebhookVerificationError, status: 401 @@ -17,4 +18,9 @@ class WebhooksController < ApplicationController head 400 end end + + def authorize_net + PaymentTransaction::AuthorizeNet.receive_webhook(request) + head 200 + end end diff --git a/app/helpers/user_upgrades_helper.rb b/app/helpers/user_upgrades_helper.rb deleted file mode 100644 index c3f7b1116..000000000 --- a/app/helpers/user_upgrades_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module UserUpgradesHelper - def cents_to_usd(cents) - number_to_currency(cents / 100, precision: 0) - end -end diff --git a/app/logical/authorize_net_client.rb b/app/logical/authorize_net_client.rb new file mode 100644 index 000000000..3f30850f1 --- /dev/null +++ b/app/logical/authorize_net_client.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +# An API client for Authorize.net. Used for processing payments for user upgrades. +# +# https://developer.authorize.net/api.html +# https://developer.authorize.net/api/reference/index.html +class AuthorizeNetClient + class Error < StandardError; end + + attr_reader :login_id, :transaction_key, :test_mode, :http + + def initialize(login_id: Danbooru.config.authorize_net_login_id, transaction_key: Danbooru.config.authorize_net_transaction_key, test_mode: Danbooru.config.authorize_net_test_mode, http: Danbooru::Http.new) + @login_id = login_id + @transaction_key = transaction_key + @test_mode = test_mode + @http = http + end + + concerning :ApiMethods do + def authenticate_test + post!( + authenticateTestRequest: { + merchantAuthentication: { + name: login_id, + transactionKey: transaction_key, + } + } + ) + end + + # https://developer.authorize.net/api/reference/index.html#transaction-reporting-get-transaction-details + def get_transaction(transaction_id) + post!( + getTransactionDetailsRequest: { + merchantAuthentication: { + name: login_id, + transactionKey: transaction_key, + }, + transId: transaction_id, + } + ) + end + + # https://developer.authorize.net/api/reference/index.html#accept-suite-get-an-accept-payment-page + def get_hosted_payment_page(reference_id:, settings: {}, **transaction_request) + post!( + getHostedPaymentPageRequest: { + merchantAuthentication: { + name: login_id, + transactionKey: transaction_key, + }, + refId: reference_id, + transactionRequest: transaction_request, + "hostedPaymentSettings": { + "setting": hosted_payment_settings(settings), + }, + } + ) + end + + def hosted_payment_settings(settings) + settings.map do |name, hash| + { + "settingName": "hostedPayment#{name.to_s.camelize}Options", + "settingValue": hash.to_json, + } + end + end + + def post!(**request) + resp = http.post!(api_url, json: request) + + body = resp.body.to_s.delete_prefix("\xEF\xBB\xBF") # delete UTF-8 BOM + json = JSON.parse(body).with_indifferent_access + + if json.dig(:messages, :resultCode) != "Ok" + code = json.dig(:messages, :message, 0, :code) + text = json.dig(:messages, :message, 0, :text) + raise Error, "Authorize.net call failed (request=#{request.keys.first} code=#{code} text=#{text})" + else + json + end + end + + # https://developer.authorize.net/api/reference/index.html#gettingstarted-section-section-header + def api_url + if test_mode + "https://apitest.authorize.net/xml/v1/request.api" + else + "https://api.authorize.net/xml/v1/request.api" + end + end + + # https://developer.authorize.net/api/reference/features/accept_hosted.html#Form_POST_URLs + def payment_page_url + if test_mode + "https://test.authorize.net/payment/payment" + else + "https://accept.authorize.net/payment/payment" + end + end + end + + # https://developer.authorize.net/api/reference/features/webhooks.html + concerning :WebhookApiMethods do + # https://developer.authorize.net/api/reference/features/webhooks.html#List_My_Webhooks + def webhooks + webhook_get!("webhooks") + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#Get_a_Webhook + def webhook(webhook_id) + webhook_get!("webhooks/#{webhook_id}") + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#Retrieve_Notification_History + def notifications(status: nil) + webhook_get!("notifications", params: { deliveryStatus: status }.compact) + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#Retrieve_a_Specific_Notification's_History + def notification(notification_id) + webhook_get!("notifications/#{notification_id}") + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#Create_A_Webhook + def create_webhook(name:, url:, eventTypes:, status: "active") + webhook_post!("webhooks", form: { name: name, url: url, eventTypes: eventTypes, status: status }) + end + + def webhook_get!(path, **options) + http.basic_auth(user: login_id, pass: transaction_key).get!(webhook_url(path), **options).parse + end + + def webhook_post!(path, **options) + http.basic_auth(user: login_id, pass: transaction_key).post!(webhook_url(path), **options).parse + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#API_Endpoint_Hosts + def webhook_url(path) + if test_mode + "https://apitest.authorize.net/rest/v1/#{path}" + else + "https://api.authorize.net/rest/v1/#{path}" + end + end + end +end diff --git a/app/logical/payment_transaction/authorize_net.rb b/app/logical/payment_transaction/authorize_net.rb new file mode 100644 index 000000000..6bbba0e8f --- /dev/null +++ b/app/logical/payment_transaction/authorize_net.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# https://sandbox.authorize.net/ +# https://developer.authorize.net/hello_world.html +# https://developer.authorize.net/api/reference/index.html +# https://developer.authorize.net/api/reference/features/accept_hosted.html +# https://developer.authorize.net/hello_world/testing_guide.html +class PaymentTransaction::AuthorizeNet < PaymentTransaction + extend Memoist + + class InvalidWebhookError < StandardError; end + + def create!(country: "US", allow_promotion_codes: false) + # https://developer.authorize.net/api/reference/index.html#accept-suite-get-an-accept-payment-page + response = api_client.get_hosted_payment_page( + reference_id: user_upgrade.id, + transactionType: "authCaptureTransaction", + amount: user_upgrade.price, + customer: { + id: user_upgrade.purchaser.id, + email: user_upgrade.purchaser.email_address&.address, + }, + settings: { + button: { + text: "Pay", + }, + order: { + show: false, + merchantName: Danbooru.config.canonical_app_name, + }, + payment: { + cardCodeRequired: true, + showCreditCard: true, + showBankAccount: false, + }, + customer: { + showEmail: true, requiredEmail: true, addPaymentProfile: false + }, + billing_address: { + show: true, + required: false, + }, + shipping_address: { + show: false, + required: false, + }, + style: { bgColor: "blue" }, + return: { + url: Routes.user_upgrade_url(user_upgrade), + cancelUrl: Routes.new_user_upgrade_url(user_id: recipient.id), + urlText: "Continue", + cancelUrlText: "Cancel", + showReceipt: true, + }, + } + ) + + [api_client.payment_page_url, response[:token]] + end + + def refund!(reason = nil) + raise NotImplementedError + end + + concerning :WebhookMethods do + class_methods do + # https://developer.authorize.net/api/reference/features/webhooks.html#Event_Types_and_Payloads + def receive_webhook(request) + verify_webhook!(request) + + case request.params[:eventType] + when "net.authorize.payment.authcapture.created" + payment_completed(request) + end + end + + # https://developer.authorize.net/api/reference/features/webhooks.html#Verifying_the_Notification + private def verify_webhook!(request) + payload = request.body.read + actual_signature = request.headers["X-Anet-Signature"].to_s + calculated_signature = "sha512=" + OpenSSL::HMAC.digest("sha512", Danbooru.config.authorize_net_signature_key, payload).unpack1("H*").upcase + raise InvalidWebhookError unless ActiveSupport::SecurityUtils::secure_compare(actual_signature, calculated_signature) + + request + end + + private def payment_completed(request) + # Authorize.net's shitty API sends a real request with fake values when you trigger a test webhook. + # The only way to detect a test webhook is to check for these hardcoded fake values. + if request.params.dig(:payload, :authAmount) == 12.5 && request.params.dig(:payload, :id) == "245" && request.params.dig(:payload, :authCode) == "572" + return + end + + user_upgrade_id = request.params.dig(:payload, :merchantReferenceId) + transaction_id = request.params.dig(:payload, :id) + user_upgrade = UserUpgrade.find(user_upgrade_id) + user_upgrade.update!(transaction_id: transaction_id) + user_upgrade.process_upgrade!("paid") + end + + private def register_webhook + raise NotImplementedError + end + end + end + + def receipt_url + # "https://sandbox.authorize.net/ui/themes/sandbox/Transaction/TransactionReceipt.aspx?transid=#{transaction_id}" if transaction_id.present? + end + + def payment_url + # "https://sandbox.authorize.net/ui/themes/sandbox/transaction/transactiondetail.aspx?transID=40092238841" if transaction_id.present? + end + + def transaction + return nil if user_upgrade.transaction_id.nil? + api_client.get_transaction(user_upgrade.transaction_id) + end + + def api_client + AuthorizeNetClient.new + end + + memoize :api_client, :transaction +end diff --git a/app/models/user_upgrade.rb b/app/models/user_upgrade.rb index d824a041e..61d102b30 100644 --- a/app/models/user_upgrade.rb +++ b/app/models/user_upgrade.rb @@ -21,6 +21,7 @@ class UserUpgrade < ApplicationRecord enum payment_processor: { stripe: 0, + authorize_net: 100, } scope :gifted, -> { where("recipient_id != purchaser_id") } @@ -32,9 +33,9 @@ class UserUpgrade < ApplicationRecord def self.gold_price if Danbooru.config.is_promotion? - 1500 + 15.00 else - 2000 + 20.00 end end @@ -46,6 +47,17 @@ class UserUpgrade < ApplicationRecord platinum_price - gold_price end + def price + case upgrade_type + in "gold" + UserUpgrade.gold_price + in "platinum" + UserUpgrade.platinum_price + in "gold_to_platinum" + UserUpgrade.gold_to_platinum_price + end + end + def level case upgrade_type when "gold" @@ -157,7 +169,12 @@ class UserUpgrade < ApplicationRecord end def transaction - PaymentTransaction::Stripe.new(self) + case payment_processor + in "stripe" + PaymentTransaction::Stripe.new(self) + in "authorize_net" + PaymentTransaction::AuthorizeNet.new(self) + end end def has_receipt? diff --git a/app/views/user_upgrades/create.js.erb b/app/views/user_upgrades/create.js.erb index e57465e98..5069923f5 100644 --- a/app/views/user_upgrades/create.js.erb +++ b/app/views/user_upgrades/create.js.erb @@ -1,2 +1,15 @@ -var stripe = Stripe("<%= j Danbooru.config.stripe_publishable_key %>"); -stripe.redirectToCheckout({ sessionId: "<%= j @checkout.id %>" }); +<% if @user_upgrade.stripe? %> + var stripe = Stripe("<%= j Danbooru.config.stripe_publishable_key %>"); + stripe.redirectToCheckout({ sessionId: "<%= j @checkout.id %>" }); +<% elsif @user_upgrade.authorize_net? %> + $(function() { + var url = "<%= j @checkout[0] %>"; + var token = "<%= j @checkout[1] %>"; + + var $form = $('
').attr("action", url) + var $input = $('').val(token); + $form.append($input).appendTo("body").submit(); + }); +<% else %> + <% raise NotImplementedError, "payment method not implemented" %> +<% end %> diff --git a/app/views/user_upgrades/new.html.erb b/app/views/user_upgrades/new.html.erb index 1b342eadf..162dd4952 100644 --- a/app/views/user_upgrades/new.html.erb +++ b/app/views/user_upgrades/new.html.erb @@ -56,7 +56,7 @@ <% if Danbooru.config.is_promotion? %> $20 <% end %> - <%= cents_to_usd(UserUpgrade.gold_price) %> + <%= number_to_currency(UserUpgrade.gold_price) %>
One time fee
@@ -110,7 +110,7 @@ <% if @user_upgrade.purchaser.is_anonymous? %> <%= link_to "Get #{Danbooru.config.canonical_app_name} Gold", new_user_path(url: new_user_upgrade_path), class: "button-primary" %> <% elsif @user_upgrade.recipient.level <= User::Levels::MEMBER %> - <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country], promo: params[:promo]), class: "button-primary", remote: true, disable_with: "Redirecting..." %> + <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country], promo: params[:promo], payment_processor: "authorize_net"), class: "button-primary", remote: true, disable_with: "Redirecting..." %> <% else %> <%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id), class: "button-primary", disabled: true %> <% end %> diff --git a/app/views/users/_upgrade_notice.html.erb b/app/views/users/_upgrade_notice.html.erb index 756c7f19d..39cb1bd92 100644 --- a/app/views/users/_upgrade_notice.html.erb +++ b/app/views/users/_upgrade_notice.html.erb @@ -1,4 +1,4 @@
-

<%= link_to "Upgrade your account for only #{cents_to_usd(UserUpgrade.gold_price)}!", new_user_upgrade_path, id: "goto-upgrade-account" %>

+

<%= link_to "Upgrade your account for only #{number_to_currency(UserUpgrade.gold_price)}!", new_user_upgrade_path, id: "goto-upgrade-account" %>

<%= link_to "No thanks", "#", id: "hide-upgrade-account-notice" %>
diff --git a/config/danbooru_default_config.rb b/config/danbooru_default_config.rb index 9aebcd726..9d73107c8 100644 --- a/config/danbooru_default_config.rb +++ b/config/danbooru_default_config.rb @@ -402,6 +402,26 @@ module Danbooru def stripe_promotion_discount_id end + # The login ID for Authorize.net. Used for accepting payments for user upgrades. + # Signup for a test account at https://developer.authorize.net/hello_world/sandbox.html. + def authorize_net_login_id + end + + # The transaction key for Authorize.net. This is the API secret for API calls. + def authorize_net_transaction_key + end + + # The signature key for Authorize.net. Used for verifying webhooks sent by Authorize.net. + # Generate at Account > Settings > Security Settings > General Security Settings > API Credentials and Keys + def authorize_net_signature_key + end + + # Whether to use the test environment or the live environment for Authorize.net. The test environment + # allows testing payments without using real credit cards. + def authorize_net_test_mode + true + end + def twitter_api_key end diff --git a/config/routes.rb b/config/routes.rb index d5498456f..4836fad02 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,6 +283,7 @@ Rails.application.routes.draw do resources :user_name_change_requests, only: [:new, :create, :show, :index] resources :webhooks do post :receive, on: :collection + post :authorize_net, on: :collection end resources :wiki_pages, id: /.+?(?=\.json|\.xml|\.html)|.+/ do put :revert, on: :member