user upgrades: add upgrade code system.

Add a system for upgrading accounts using upgrade codes. Users purchase
an upgrade code off-site then redeem it on-site to upgrade their account
to Gold. Upgrade codes are randomly pre-generated and are one time use
only. Codes have enough randomness that guessing a code is infeasible.
This commit is contained in:
evazion
2022-05-25 17:28:39 -05:00
parent 7786fbaca6
commit 173e43b192
22 changed files with 461 additions and 22 deletions

View File

@@ -136,6 +136,8 @@ class ApplicationController < ActionController::Base
render_error_page(422, exception, template: "static/tag_limit_error", message: "You cannot search for more than #{CurrentUser.tag_query_limit} tags at a time.")
when PostQuery::Error
render_error_page(422, exception, message: exception.message)
when UpgradeCode::InvalidCodeError, UpgradeCode::RedeemedCodeError, UpgradeCode::AlreadyUpgradedError
render_error_page(422, exception, message: exception.message)
when RateLimiter::RateLimitError
render_error_page(429, exception, message: "Rate limit exceeded. You're doing that too fast")
when PageRemovedError

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
class UpgradeCodesController < ApplicationController
respond_to :js, :html, :json, :xml
def index
@upgrade_codes = authorize UpgradeCode.visible(CurrentUser.user).paginated_search(params, count_pages: true)
respond_with(@upgrade_codes)
end
def redeem
end
def upgrade
@upgrade_code = UpgradeCode.redeem!(code: params.dig(:upgrade_code, :code), redeemer: CurrentUser.user)
respond_with(@upgrade_code, location: @upgrade_code.user_upgrade)
end
end

View File

@@ -11,6 +11,9 @@ class WebhooksController < ApplicationController
when "stripe"
PaymentTransaction::Stripe.receive_webhook(request)
head 200
when "shopify"
PaymentTransaction::Shopify.receive_webhook(request)
head 200
when "discord"
json = DiscordSlashCommand.receive_webhook(request)
render json: json

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
class PaymentTransaction::Shopify < PaymentTransaction
class InvalidWebhookError < StandardError; end
def create!(**params)
nil
end
def refund!(reason = nil)
raise NotImplementedError
end
concerning :WebhookMethods do
class_methods do
def receive_webhook(request)
verify_webhook!(request)
end
private def verify_webhook!(request)
payload = request.body.read
actual_signature = request.headers["X-Shopify-Hmac-Sha256"].to_s
calculated_signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", Danbooru.config.shopify_webhook_secret, payload))
raise InvalidWebhookError unless ActiveSupport::SecurityUtils::secure_compare(actual_signature, calculated_signature)
request
end
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
# A code that can be redeemed for a Gold account. Codes are pre-generated and
# one time use only. Upgrade codes are sold in the Danbooru store.
class UpgradeCode < ApplicationRecord
class InvalidCodeError < StandardError; end
class RedeemedCodeError < StandardError; end
class AlreadyUpgradedError < StandardError; end
UPGRADE_CODE_LENGTH = 8
attribute :code, default: -> { UpgradeCode.generate_code }
attribute :status, default: :unsold
belongs_to :creator, class_name: "User"
belongs_to :redeemer, class_name: "User", optional: true
belongs_to :user_upgrade, optional: true
enum status: {
unsold: 0,
unredeemed: 100,
redeemed: 200,
}
def self.visible(user)
if user.is_owner?
all
else
where(redeemer: user).or(where(creator: user))
end
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :code, :status, :creator, :redeemer, :user_upgrade)
q.apply_default_order(params)
end
def self.generate_code
SecureRandom.send(:choose, [*"0".."9", *"A".."Z", *"a".."z"], UPGRADE_CODE_LENGTH) # base62
end
def self.redeem!(code:, redeemer:)
upgrade_code = UpgradeCode.find_by(code: code)
raise InvalidCodeError, "This upgrade code is invalid" if upgrade_code.nil?
upgrade_code.redeem!(redeemer)
end
def redeem!(redeemer)
transaction do
raise RedeemedCodeError, "This upgrade code has already been used" if redeemed?
raise AlreadyUpgradedError, "Your account is already Gold or higher" if redeemer.is_gold?
user_upgrade = UserUpgrade.create!(recipient: redeemer, purchaser: redeemer, status: "processing", upgrade_type: "gold", payment_processor: "upgrade_code")
user_upgrade.process_upgrade!("paid")
update!(status: :redeemed, redeemer: redeemer, user_upgrade: user_upgrade)
self
end
end
end

View File

@@ -22,6 +22,8 @@ class UserUpgrade < ApplicationRecord
enum payment_processor: {
stripe: 0,
authorize_net: 100,
shopify: 200,
upgrade_code: 300,
}
scope :gifted, -> { where("recipient_id != purchaser_id") }
@@ -113,7 +115,7 @@ class UserUpgrade < ApplicationRecord
end
concerning :UpgradeMethods do
def process_upgrade!(payment_status)
def process_upgrade!(payment_status = "paid")
recipient.with_lock do
return unless pending? || processing?
@@ -174,6 +176,8 @@ class UserUpgrade < ApplicationRecord
PaymentTransaction::Stripe.new(self)
in "authorize_net"
PaymentTransaction::AuthorizeNet.new(self)
in "shopify"
PaymentTransaction::Shopify.new(self)
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
class UpgradeCodePolicy < ApplicationPolicy
def index?
user.is_owner?
end
def redeem?
true
end
def upgrade?
unbanned?
end
end

View File

@@ -130,7 +130,7 @@
<li><%= link_to "Favorite groups", favorite_groups_path %></li>
<li><%= link_to "Saved searches", saved_searches_path %></li>
<% end %>
<li><%= link_to "Upgrade information", new_user_upgrade_path %></li>
<li><%= link_to "Upgrade account", new_user_upgrade_path %></li>
</ul>
<ul>
<li><h2>Users</h2></li>

View File

@@ -0,0 +1,29 @@
<div id="c-upgrade-codes">
<div id="a-index">
<%= search_form_for(upgrade_codes_path) do |f| %>
<%= f.input :redeemer_name, label: "Redeemer", input_html: { value: params[:search][:redeemer_name], data: { autocomplete: "user" } } %>
<%= f.input :status, collection: UpgradeCode.statuses, include_blank: true, selected: params[:search][:status] %>
<%= f.submit "Search" %>
<% end %>
<%= table_for @upgrade_codes, class: "striped autofit" do |t| %>
<% t.column :code do |user_upgrade| %>
<%= tag.span user_upgrade.code, class: "font-monospace" %>
<% end %>
<% t.column "Redeemer" do |upgrade_code| %>
<% if upgrade_code.redeemer.present? %>
<%= link_to_user(upgrade_code.redeemer) %>
<% end %>
<% end %>
<% t.column :status %>
<% t.column "Updated" do |upgrade_code| %>
<%= time_ago_in_words_tagged(upgrade_code.updated_at) %>
<% end %>
<% end %>
<%= numbered_paginator(@upgrade_codes) %>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<% page_title "Redeem upgrade code" %>
<%= render "users/secondary_links" %>
<div id="c-user-upgrades">
<div id="a-redeem" class="fixed-width-container mx-auto">
<h1 class="text-center mb-4">Redeem upgrade code</h1>
<% if CurrentUser.user.level <= User::Levels::MEMBER %>
<p>Enter your upgrade code below to upgrade your account to Gold.</p>
<p>After you purchase a Gold account, you will receive an upgrade code. Enter it here to upgrade your account. If you don't have a code,
go to the <%= link_to "upgrade page", new_user_upgrade_path %> first to buy one, then come back here to redeem it.</p>
<% if CurrentUser.user.is_anonymous? %>
<p>You must <%= link_to "login", login_path(url: redeem_upgrade_codes_path) %> or
<%= link_to "create a new account", new_user_path(url: redeem_upgrade_codes_path) %> first before you can redeem your upgrade code.</p>
<% else %>
<%= simple_form_for(:upgrade_code, url: upgrade_upgrade_codes_path, remote: true) do |f| %>
<%= f.input :code, placeholder: "abcd1234", input_html: { value: params[:code] } %>
<%= f.submit "Upgrade account", class: "button-primary" %>
<% end %>
<% end %>
<% else %>
<p class="text-center">Your account has already been upgraded.</p>
<% end %>
</div>
</div>

View File

@@ -0,0 +1 @@
window.location.assign("<%= j user_upgrade_path(@upgrade_code.user_upgrade) %>");

View File

@@ -10,6 +10,9 @@
var $input = $('<input type="hidden" name="token">').val(token);
$form.append($input).appendTo("body").submit();
});
<% elsif @user_upgrade.shopify? %>
window.history.pushState({}, "", location.href);
window.location.assign("<%= j Danbooru.config.shopify_purchase_url %>?attributes[user_upgrade_id]=<%= @user_upgrade.id %>&attributes[purchaser_id]=<%= @user_upgrade.purchaser_id %>&attributes[purchaser_name]=<%= @user_upgrade.purchaser.name %>&checkout[email]=<%= @user_upgrade.purchaser&.email_address&.address %>");
<% else %>
<% raise NotImplementedError, "payment method not implemented" %>
<% end %>

View File

@@ -56,7 +56,7 @@
<% if Danbooru.config.is_promotion? %>
<s>$20</s>
<% end %>
<b><%= number_to_currency(UserUpgrade.gold_price) %></b>
<b><%= number_to_currency(UserUpgrade.gold_price, precision: 0) %></b>
<div class="fineprint">One time fee</div>
</td>
</tr>
@@ -90,15 +90,6 @@
<td>3 seconds</td>
<td>6 seconds</td>
</tr>
<tr>
<td></td>
<td></td>
<td>
<%= image_pack_tag("static/mastercard-logo.svg", width: 28, class: "icon") %>
<%= image_pack_tag("static/visa-logo.svg", width: 28, class: "icon") %>
<%= image_pack_tag("static/discover-logo.svg", width: 28, class: "icon") %>
</td>
</tr>
<tr>
<td></td>
<td>
@@ -108,13 +99,15 @@
</td>
<td>
<% if !UserUpgrade.enabled? %>
<%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id), class: "button-primary", disabled: true %>
<%= link_to "Upgrade to Gold", user_upgrades_path(user_id: @recipient.id), class: "button-primary", disabled: true %>
<% elsif @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" %>
<%= link_to "Upgrade to Gold", new_user_path(url: new_user_upgrade_path), class: "button-primary" %>
<%= link_to "Redeem upgrade code", redeem_upgrade_codes_path, class: "text-sm" %>
<% 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], payment_processor: "authorize_net"), class: "button-primary", remote: true, disable_with: "Redirecting..." %>
<%= button_to "Upgrade to Gold", user_upgrades_path(user_id: @recipient.id, upgrade_type: "gold", country: params[:country], promo: params[:promo], payment_processor: "shopify"), class: "button-primary mb-2", remote: true, disable_with: "Redirecting..." %>
<%= link_to "Redeem upgrade code", redeem_upgrade_codes_path, class: "text-sm" %>
<% else %>
<%= button_to "Get #{Danbooru.config.canonical_app_name} Gold", user_upgrades_path(user_id: @recipient.id), class: "button-primary", disabled: true %>
<%= link_to "Upgrade to Gold", user_upgrades_path(user_id: @recipient.id), class: "button-primary", disabled: true %>
<% end %>
</td>
</tr>
@@ -130,6 +123,13 @@
<h2 class="mb-4">Frequently Asked Questions</h2>
<div id="frequently-asked-questions" class="divide-y-1">
<details>
<summary>How do I buy <%= Danbooru.config.canonical_app_name %> Gold?</summary>
<p>Click the "Upgrade to Gold" button on this page. After you purchase an upgrade, you will receive a code you can
<%= link_to "redeem here", redeem_upgrade_codes_path %> to upgrade your account to Gold.</p>
</details>
<details>
<summary>What are the benefits of <%= Danbooru.config.canonical_app_name %> Gold?</summary>

View File

@@ -37,14 +37,10 @@
<% else %>
<p>You are now a <%= @user_upgrade.level_string %> user. Thanks for supporting the site! A receipt has been sent to your email.</p>
<% end %>
<%= render "stripe_links", user_upgrade: @user_upgrade %>
<% elsif @user_upgrade.refunded? %>
<p>This purchase has been refunded. A receipt has been sent to your email. It can take up to
5-10 days for the refund to appear on your credit card or bank statement. If it takes longer,
please contact your bank for assistance.</p>
<%= render "stripe_links", user_upgrade: @user_upgrade %>
<% else %>
<%= content_for :html_header do %>
<meta http-equiv="refresh" content="5">

View File

@@ -1,4 +1,4 @@
<div class="notice notice-info notice-large" id="upgrade-account-notice">
<h2><%= link_to "Upgrade your account for only #{number_to_currency(UserUpgrade.gold_price)}!", new_user_upgrade_path, id: "goto-upgrade-account" %></h2>
<h2><%= link_to "Upgrade your account for only #{number_to_currency(UserUpgrade.gold_price, precision: 0)}!", new_user_upgrade_path, id: "goto-upgrade-account" %></h2>
<div><%= link_to "No thanks", "#", id: "hide-upgrade-account-notice" %></div>
</div>