Skip to content

Commit

Permalink
Merge pull request #1455 from Shopify/add_billing_support
Browse files Browse the repository at this point in the history
Add support for billing for apps
  • Loading branch information
paulomarg committed Jun 17, 2022
2 parents 8c2d96e + ea320bd commit 9a3e49b
Show file tree
Hide file tree
Showing 14 changed files with 662 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Unreleased

* Add the `login_callback_url` config to allow overwriting that route as well, and mount the engine routes based on the configurations. [#1445](https://github.com/Shopify/shopify_app/pull/1445)
* Add special headers when returning 401s from LoginProtection. [#1450](https://github.com/Shopify/shopify_app/pull/1450)
* Add a new `billing` configuration which takes in a `ShopifyApp::BillingConfiguration` object and checks for payment on controllers with `Authenticated`. [#1455](https://github.com/Shopify/shopify_app/pull/1455)

19.0.2 (April 27, 2022)
----------
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/concerns/shopify_app/authenticated.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ module Authenticated
include ShopifyApp::LoginProtection
include ShopifyApp::CsrfProtection
include ShopifyApp::EmbeddedApp
include ShopifyApp::EnsureBilling

before_action :login_again_if_different_user_or_shop
around_action :activate_shopify_session
after_action :add_top_level_redirection_headers
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/shopify_app/callback_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module ShopifyApp
# Performs login after OAuth completes
class CallbackController < ActionController::Base
include ShopifyApp::LoginProtection
include ShopifyApp::EnsureBilling

def callback
begin
Expand Down Expand Up @@ -34,8 +35,9 @@ def callback
end

perform_post_authenticate_jobs(auth_result[:session])
has_payment = check_billing(auth_result[:session])

respond_successfully
respond_successfully if has_payment
end

private
Expand Down
13 changes: 13 additions & 0 deletions lib/generators/shopify_app/install/templates/shopify_app.rb.tt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ ShopifyApp.configure do |config|
config.api_key = ENV.fetch('SHOPIFY_API_KEY', '').presence
config.secret = ENV.fetch('SHOPIFY_API_SECRET', '').presence

# You may want to charge merchants for using your app. Setting the billing configuration will cause the Authenticated
# controller concern to check that the session is for a merchant that has an active one-time payment or subscription.
# If no payment is found, it starts off the process and sends the merchant to a confirmation URL so that they can
# approve the purchase.
#
# Learn more about billing in our documentation: https://shopify.dev/apps/billing
# config.billing = ShopifyApp::BillingConfiguration.new(
# charge_name: "My app billing charge",
# amount: 5,
# interval: ShopifyApp::BillingConfiguration::INTERVAL_EVERY_30_DAYS,
# currency_code: "USD", # Only supports USD for now
# )

if defined? Rails::Server
raise('Missing SHOPIFY_API_KEY. See https://github.com/Shopify/shopify_app#requirements') unless config.api_key
raise('Missing SHOPIFY_API_SECRET. See https://github.com/Shopify/shopify_app#requirements') unless config.secret
Expand Down
1 change: 1 addition & 0 deletions lib/shopify_app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def self.use_webpacker?
require "shopify_app/controller_concerns/localization"
require "shopify_app/controller_concerns/itp"
require "shopify_app/controller_concerns/login_protection"
require "shopify_app/controller_concerns/ensure_billing"
require "shopify_app/controller_concerns/embedded_app"
require "shopify_app/controller_concerns/payload_verification"
require "shopify_app/controller_concerns/app_proxy_verification"
Expand Down
25 changes: 25 additions & 0 deletions lib/shopify_app/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class Configuration
# allow namespacing webhook jobs
attr_accessor :webhook_jobs_namespace

# takes a ShopifyApp::BillingConfiguration object
attr_accessor :billing

def initialize
@root_url = "/"
@myshopify_domain = "myshopify.com"
Expand Down Expand Up @@ -90,6 +93,10 @@ def has_scripttags?
scripttags.present?
end

def requires_billing?
billing.present?
end

def shop_access_scopes
@shop_access_scopes || scope
end
Expand All @@ -99,6 +106,24 @@ def user_access_scopes
end
end

class BillingConfiguration
INTERVAL_ONE_TIME = "ONE_TIME"
INTERVAL_EVERY_30_DAYS = "EVERY_30_DAYS"
INTERVAL_ANNUAL = "ANNUAL"

attr_reader :charge_name
attr_reader :amount
attr_reader :currency_code
attr_reader :interval

def initialize(charge_name:, amount:, interval:, currency_code: "USD")
@charge_name = charge_name
@amount = amount
@currency_code = currency_code
@interval = interval
end
end

def self.configuration
@configuration ||= Configuration.new
end
Expand Down
254 changes: 254 additions & 0 deletions lib/shopify_app/controller_concerns/ensure_billing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# frozen_string_literal: true

module ShopifyApp
module EnsureBilling
class BillingError < StandardError
attr_accessor :message
attr_accessor :errors

def initialize(message, errors)
super
@message = message
@errors = errors
end
end

extend ActiveSupport::Concern

RECURRING_INTERVALS = [BillingConfiguration::INTERVAL_EVERY_30_DAYS, BillingConfiguration::INTERVAL_ANNUAL]

included do
before_action :check_billing, if: :billing_required?
rescue_from BillingError, with: :handle_billing_error
end

private

def check_billing(session = current_shopify_session)
return true if session.blank? || !billing_required?

confirmation_url = nil

if has_active_payment?(session)
has_payment = true
else
has_payment = false
confirmation_url = request_payment(session)
end

unless has_payment
if request.xhr?
add_top_level_redirection_headers(url: confirmation_url, ignore_response_code: true)
head(:unauthorized)
else
redirect_to(confirmation_url, allow_other_host: true)
end
end

has_payment
end

def billing_required?
ShopifyApp.configuration.requires_billing?
end

def handle_billing_error(error)
logger.info("#{error.message}: #{error.errors}")
redirect_to_login
end

def has_active_payment?(session)
if recurring?
has_subscription?(session)
else
has_one_time_payment?(session)
end
end

def has_subscription?(session)
response = run_query(session: session, query: RECURRING_PURCHASES_QUERY)
subscriptions = response.body["data"]["currentAppInstallation"]["activeSubscriptions"]

subscriptions.each do |subscription|
if subscription["name"] == ShopifyApp.configuration.billing.charge_name &&
(!Rails.env.production? || !subscription["test"])

return true
end
end

false
end

def has_one_time_payment?(session)
purchases = nil
end_cursor = nil

loop do
response = run_query(session: session, query: ONE_TIME_PURCHASES_QUERY, variables: { endCursor: end_cursor })
purchases = response.body["data"]["currentAppInstallation"]["oneTimePurchases"]

purchases["edges"].each do |purchase|
node = purchase["node"]

if node["name"] == ShopifyApp.configuration.billing.charge_name &&
(!Rails.env.production? || !node["test"]) &&
node["status"] == "ACTIVE"

return true
end
end

end_cursor = purchases["pageInfo"]["endCursor"]
break unless purchases["pageInfo"]["hasNextPage"]
end

false
end

def request_payment(session)
shop = session.shop
host = Base64.encode64("#{shop}/admin")
return_url = "https://#{ShopifyAPI::Context.host_name}?shop=#{shop}&host=#{host}"

if recurring?
data = request_recurring_payment(session: session, return_url: return_url)
data = data["data"]["appSubscriptionCreate"]
else
data = request_one_time_payment(session: session, return_url: return_url)
data = data["data"]["appPurchaseOneTimeCreate"]
end

raise BillingError.new("Error while billing the store", data["userErrros"]) unless data["userErrors"].empty?

data["confirmationUrl"]
end

def request_recurring_payment(session:, return_url:)
response = run_query(
session: session,
query: RECURRING_PURCHASE_MUTATION,
variables: {
name: ShopifyApp.configuration.billing.charge_name,
lineItems: {
plan: {
appRecurringPricingDetails: {
interval: ShopifyApp.configuration.billing.interval,
price: {
amount: ShopifyApp.configuration.billing.amount,
currencyCode: ShopifyApp.configuration.billing.currency_code,
},
},
},
},
returnUrl: return_url,
test: !Rails.env.production?,
}
)

response.body
end

def request_one_time_payment(session:, return_url:)
response = run_query(
session: session,
query: ONE_TIME_PURCHASE_MUTATION,
variables: {
name: ShopifyApp.configuration.billing.charge_name,
price: {
amount: ShopifyApp.configuration.billing.amount,
currencyCode: ShopifyApp.configuration.billing.currency_code,
},
returnUrl: return_url,
test: !Rails.env.production?,
}
)

response.body
end

def recurring?
RECURRING_INTERVALS.include?(ShopifyApp.configuration.billing.interval)
end

def run_query(session:, query:, variables: nil)
client = ShopifyAPI::Clients::Graphql::Admin.new(session: session)

response = client.query(query: query, variables: variables)

raise BillingError.new("Error while billing the store", []) unless response.ok?
raise BillingError.new("Error while billing the store", response.body["errors"]) if response.body["errors"]

response
end

RECURRING_PURCHASES_QUERY = <<~'QUERY'
query appSubscription {
currentAppInstallation {
activeSubscriptions {
name, test
}
}
}
QUERY

ONE_TIME_PURCHASES_QUERY = <<~'QUERY'
query appPurchases($endCursor: String) {
currentAppInstallation {
oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) {
edges {
node {
name, test, status
}
}
pageInfo {
hasNextPage, endCursor
}
}
}
}
QUERY

RECURRING_PURCHASE_MUTATION = <<~'QUERY'
mutation createPaymentMutation(
$name: String!
$lineItems: [AppSubscriptionLineItemInput!]!
$returnUrl: URL!
$test: Boolean
) {
appSubscriptionCreate(
name: $name
lineItems: $lineItems
returnUrl: $returnUrl
test: $test
) {
confirmationUrl
userErrors {
field, message
}
}
}
QUERY

ONE_TIME_PURCHASE_MUTATION = <<~'QUERY'
mutation createPaymentMutation(
$name: String!
$price: MoneyInput!
$returnUrl: URL!
$test: Boolean
) {
appPurchaseOneTimeCreate(
name: $name
price: $price
returnUrl: $returnUrl
test: $test
) {
confirmationUrl
userErrors {
field, message
}
}
}
QUERY
end
end
6 changes: 4 additions & 2 deletions lib/shopify_app/controller_concerns/login_protection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def jwt_expire_at
expire_at - 5.seconds # 5s gap to start fetching new token in advance
end

def add_top_level_redirection_headers(ignore_response_code: false)
def add_top_level_redirection_headers(url: nil, ignore_response_code: false)
if request.xhr? && (ignore_response_code || response.code.to_i == 401)
# Make sure the shop is set in the redirection URL
unless params[:shop]
Expand All @@ -82,8 +82,10 @@ def add_top_level_redirection_headers(ignore_response_code: false)
end
end

url ||= login_url_with_optional_shop

response.set_header("X-Shopify-API-Request-Failure-Reauthorize", "1")
response.set_header("X-Shopify-API-Request-Failure-Reauthorize-Url", login_url_with_optional_shop)
response.set_header("X-Shopify-API-Request-Failure-Reauthorize-Url", url)
end
end

Expand Down
Loading

0 comments on commit 9a3e49b

Please sign in to comment.