11 Commits
Author SHA1 Message Date
Blake Erickson 7fe7be7ef8 add nonce to pricing-table.js 2024-04-24 11:38:56 -06:00
Blake Erickson d6c4ec21ae add pricing table to csp 2024-04-24 11:18:47 -06:00
Blake Erickson c48cbc7071 fix spec still linking to /s 2024-04-24 11:12:48 -06:00
spirobel 66cf05943c DEV: Change add_admin_route from products to plans 2024-04-24 11:11:38 -06:00
Blake Erickson e46693b94e linting 2024-04-24 10:09:53 -06:00
spirobel fcc414dbbe DEV: fix backend tests
After the engine url got moved to /subscriptions, the tests had to be adapted.

The subscribe controller does not return products anymore after moving to the stripe pricing table, so the respective tests were removed.
2024-04-23 16:24:34 -06:00
spirobel 411ac6ec80 DEV: add frontend tests to make sure pricing-table works
We test three conditions:
1. Make sure the customer email is prefilled, if there is a logged in discourse user
2. The pricing table also displays, if there is no logged in discourse user
3. If the pricing table is not configured, the pricing table element does not show up.
2024-04-23 15:32:19 -06:00
spirobel ce988c9512 DEV: continue to use subscribe_controller instead of introducting a new controller 2024-04-23 15:30:28 -06:00
spirobel bf7079928e DEV: fix linter issues 2024-04-23 15:26:52 -06:00
spirobel ff981f76e8 DEV: Remove the old product / pricing table display routes 2024-04-23 15:24:58 -06:00
spirobel 13d23dc3fa FEATURE: Add new stripe based pricing table
This commit adds the new stripe based pricing table.
If the user is logged in, the email field will be prepopulated with the users email.

The pricing table can be configured in the stripe dashboard.

Once the discourse_subscriptions_pricing_table setting is filled with the pricing table embed code from the stripe dashboard,
the pricing table will be displayed on /subscriptions

For more details see https://stripe.com/docs/payments/checkout/pricing-table
2024-04-23 15:18:31 -06:00
200 changed files with 6535 additions and 9206 deletions
-4
View File
@@ -1,7 +1,3 @@
< 3.5.0.beta5-dev: 7309c937dddb1933ac66aa2d3e13d071060d30ff
< 3.5.0.beta1-dev: b4f7be4274ada9400238f8692e705669034c1709
< 3.4.0.beta2-dev: 692f410ab59e329d0aaf537cd772698f83c32a8a
< 3.4.0.beta1-dev: bb96d77056a9ef842e01e46fd980320d427073ad
< 3.3.0.beta1-dev: 0dcf17de59bdf82c0233bb630dad0ce407d1ae76 < 3.3.0.beta1-dev: 0dcf17de59bdf82c0233bb630dad0ce407d1ae76
3.1.999: c4f8220a5572ef9a42a9a71500e001d0df206c43 3.1.999: c4f8220a5572ef9a42a9a71500e001d0df206c43
2.9.0.beta9: 39b7253c082270e33839f4b0821fbf75abfdd826 2.9.0.beta9: 39b7253c082270e33839f4b0821fbf75abfdd826
+1
View File
@@ -0,0 +1 @@
module.exports = require("@discourse/lint-configs/eslint");
-2
View File
@@ -1,2 +0,0 @@
engine-strict = true
auto-install-peers = false
+44 -68
View File
@@ -7,95 +7,71 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
activesupport (8.0.2) activesupport (7.1.3.2)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) mutex_m
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0)
uri (>= 0.13.1) ast (2.4.2)
ast (2.4.3) base64 (0.2.0)
base64 (0.3.0) bigdecimal (3.1.6)
benchmark (0.4.1) concurrent-ruby (1.2.3)
bigdecimal (3.2.0) connection_pool (2.4.1)
concurrent-ruby (1.3.5) drb (2.2.1)
connection_pool (2.5.3) i18n (1.14.3)
drb (2.2.3)
i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
json (2.12.2) racc (~> 1.7)
language_server-protocol (3.17.0.5) json (2.7.1)
lint_roller (1.1.0) language_server-protocol (3.17.0.3)
logger (1.7.0) minitest (5.22.2)
minitest (5.25.5) mutex_m (0.2.0)
parallel (1.27.0) parallel (1.24.0)
parser (3.3.8.0) parser (3.3.0.5)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
prettier_print (1.2.1) prettier_print (1.2.1)
prism (1.4.0) racc (1.7.3)
racc (1.8.1)
rack (3.1.16)
rainbow (3.1.1) rainbow (3.1.1)
regexp_parser (2.10.0) regexp_parser (2.9.0)
rubocop (1.75.8) rexml (3.2.6)
rubocop (1.62.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (>= 3.17.0)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 1.8, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.44.1) rubocop-ast (1.31.1)
parser (>= 3.3.7.2) parser (>= 3.3.0.4)
prism (~> 1.4) rubocop-capybara (2.20.0)
rubocop-capybara (2.22.1) rubocop (~> 1.41)
lint_roller (~> 1.1) rubocop-discourse (3.7.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-discourse (3.12.1)
activesupport (>= 6.1) activesupport (>= 6.1)
lint_roller (>= 1.1.0) rubocop (>= 1.59.0)
rubocop (>= 1.73.2) rubocop-capybara (>= 2.0.0)
rubocop-capybara (>= 2.22.0) rubocop-factory_bot (>= 2.0.0)
rubocop-factory_bot (>= 2.27.0) rubocop-rspec (>= 2.25.0)
rubocop-rails (>= 2.30.3) rubocop-factory_bot (2.25.1)
rubocop-rspec (>= 3.0.1) rubocop (~> 1.41)
rubocop-rspec_rails (>= 2.31.0) rubocop-rspec (2.27.1)
rubocop-factory_bot (2.27.1) rubocop (~> 1.40)
lint_roller (~> 1.1) rubocop-capybara (~> 2.17)
rubocop (~> 1.72, >= 1.72.1) rubocop-factory_bot (~> 2.22)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
securerandom (0.4.1)
syntax_tree (6.2.0) syntax_tree (6.2.0)
prettier_print (>= 1.2.0) prettier_print (>= 1.2.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.1.4) unicode-display_width (2.5.0)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
PLATFORMS PLATFORMS
ruby ruby
@@ -106,4 +82,4 @@ DEPENDENCIES
translations-manager! translations-manager!
BUNDLED WITH BUNDLED WITH
2.6.9 2.5.4
-3
View File
@@ -1,8 +1,5 @@
# Discourse Subscriptions Plugin # Discourse Subscriptions Plugin
> [!IMPORTANT]
> This plugin has now been bundled into Discourse core. See: https://meta.discourse.org/t/bundling-more-popular-plugins-with-discourse-core/373574
Discourse Subscriptions allows site owners to sell recurring and one-time purchase subscriptions that grant access to a group on a Discourse instance. Discourse Subscriptions allows site owners to sell recurring and one-time purchase subscriptions that grant access to a group on a Discourse instance.
For more information, please see: https://meta.discourse.org/t/discourse-subscriptions-plugin/140818 For more information, please see: https://meta.discourse.org/t/discourse-subscriptions-plugin/140818
@@ -53,7 +53,7 @@ module DiscourseSubscriptions
params.require(:id) params.require(:id)
begin begin
refund_subscription(params[:id]) if params[:refund] refund_subscription(params[:id]) if params[:refund]
subscription = ::Stripe::Subscription.cancel(params[:id]) subscription = ::Stripe::Subscription.delete(params[:id])
customer = customer =
Customer.find_by( Customer.find_by(
@@ -61,8 +61,11 @@ module DiscourseSubscriptions
customer_id: subscription[:customer], customer_id: subscription[:customer],
) )
Subscription.delete_by(external_id: params[:id])
if customer if customer
user = ::User.find(customer.user_id) user = ::User.find(customer.user_id)
customer.delete
group = plan_group(subscription[:plan]) group = plan_group(subscription[:plan])
group.remove(user) if group group.remove(user) if group
end end
@@ -80,7 +83,6 @@ module DiscourseSubscriptions
expand: ["data.plan.product"], expand: ["data.plan.product"],
limit: PAGE_LIMIT, limit: PAGE_LIMIT,
starting_after: start, starting_after: start,
status: "all",
) )
end end
@@ -9,7 +9,6 @@ module DiscourseSubscriptions
layout false layout false
before_action :set_api_key
skip_before_action :check_xhr skip_before_action :check_xhr
skip_before_action :redirect_to_login_if_required skip_before_action :redirect_to_login_if_required
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
@@ -28,121 +27,48 @@ module DiscourseSubscriptions
end end
case event[:type] case event[:type]
when "checkout.session.completed"
checkout_session = event[:data][:object]
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("checkout.session.completed data: #{checkout_session}")
end
email = checkout_session[:customer_email]
return head 200 if checkout_session[:status] != "complete"
return render_json_error "email not found" if !email
if checkout_session[:customer].nil?
customer = ::Stripe::Customer.create({ email: email })
customer_id = customer[:id]
else
customer_id = checkout_session[:customer]
end
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("Looking up user with email: #{email}")
end
user = ::User.find_by_username_or_email(email)
return render_json_error "user not found" if !user
discourse_customer = Customer.create(user_id: user.id, customer_id: customer_id)
subscription = checkout_session[:subscription]
if subscription.present?
Subscription.create(customer_id: discourse_customer.id, external_id: subscription)
end
line_items =
::Stripe::Checkout::Session.list_line_items(checkout_session[:id], { limit: 1 })
item = line_items[:data].first
group = plan_group(item[:price])
group.add(user) unless group.nil?
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("Line item with group name meta data: #{item[:price]}")
if group.nil?
Rails.logger.warn("Group not found or not listed in metadata!")
else
Rails.logger.warn("Group: #{group.name}")
end
end
discourse_customer.product_id = item[:price][:product]
discourse_customer.save!
if !subscription.nil?
::Stripe::Subscription.update(
subscription,
{ metadata: { user_id: user.id, username: user.username } },
)
end
when "customer.subscription.created" when "customer.subscription.created"
when "customer.subscription.updated" when "customer.subscription.updated"
subscription = event[:data][:object] customer =
status = subscription[:status] Customer.find_by(
return head 200 if !%w[complete active].include?(status) customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product],
customer = find_active_customer(subscription[:customer], subscription[:plan][:product]) )
return render_json_error "customer not found" if !customer return render_json_error "customer not found" if !customer
return head 200 if event[:data][:object][:status] != "complete"
update_status(customer.id, subscription[:id], status)
user = ::User.find_by(id: customer.user_id) user = ::User.find_by(id: customer.user_id)
return render_json_error "user not found" if !user return render_json_error "user not found" if !user
if group = plan_group(subscription[:plan]) if group = plan_group(event[:data][:object][:plan])
group.add(user) group.add(user)
end end
when "customer.subscription.deleted" when "customer.subscription.deleted"
subscription = event[:data][:object] customer =
Customer.find_by(
customer = find_active_customer(subscription[:customer], subscription[:plan][:product]) customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product],
)
return render_json_error "customer not found" if !customer return render_json_error "customer not found" if !customer
update_status(customer.id, subscription[:id], subscription[:status]) Subscription.find_by(
customer_id: customer.id,
external_id: event[:data][:object][:id],
)&.destroy!
user = ::User.find(customer.user_id) user = ::User.find(customer.user_id)
return render_json_error "user not found" if !user return render_json_error "user not found" if !user
if group = plan_group(subscription[:plan]) if group = plan_group(event[:data][:object][:plan])
group.remove(user) group.remove(user)
end end
customer.destroy!
end end
head 200 head 200
end end
private
def update_status(customer_id, subscription_id, status)
discourse_subscription =
Subscription.find_by(customer_id: customer_id, external_id: subscription_id)
discourse_subscription.update(status: status) if discourse_subscription
end
def find_active_customer(customer_id, product_id)
Customer
.joins(:subscriptions)
.where(customer_id: customer_id, product_id: product_id)
.where(
Subscription.arel_table[:status].eq(nil).or(
Subscription.arel_table[:status].not_eq("canceled"),
),
)
.first
end
end end
end end
@@ -1,11 +0,0 @@
# frozen_string_literal: true
module DiscourseSubscriptions
class PricingtableController < ::ApplicationController
requires_plugin DiscourseSubscriptions::PLUGIN_NAME
def index
head 200
end
end
end
@@ -11,20 +11,7 @@ module DiscourseSubscriptions
requires_login except: %i[index contributors show] requires_login except: %i[index contributors show]
def index def index
begin head 200
product_ids = Product.all.pluck(:external_id)
products = []
if product_ids.present? && is_stripe_configured?
response = ::Stripe::Product.list({ ids: product_ids, active: true })
products = response[:data].map { |p| serialize_product(p) }
end
render_json_dump products
rescue ::Stripe::InvalidRequestError => e
render_json_error e.message
end
end end
def contributors def contributors
@@ -85,19 +72,14 @@ module DiscourseSubscriptions
promo_code_id = promo_code[:id] if promo_code promo_code_id = promo_code[:id] if promo_code
subscription_params = { transaction =
customer: customer[:id], ::Stripe::Subscription.create(
items: [{ price: params[:plan] }], customer: customer[:id],
metadata: metadata_user, items: [{ price: params[:plan] }],
trial_period_days: trial_days, metadata: metadata_user,
promotion_code: promo_code_id, trial_period_days: trial_days,
} promotion_code: promo_code_id,
)
if SiteSetting.discourse_subscriptions_enable_automatic_tax
subscription_params[:automatic_tax] = { enabled: true }
end
transaction = ::Stripe::Subscription.create(subscription_params)
payment_intent = retrieve_payment_intent(transaction[:latest_invoice]) if transaction[ payment_intent = retrieve_payment_intent(transaction[:latest_invoice]) if transaction[
:status :status
@@ -105,28 +87,16 @@ module DiscourseSubscriptions
else else
coupon_id = promo_code[:coupon][:id] if promo_code && promo_code[:coupon] && coupon_id = promo_code[:coupon][:id] if promo_code && promo_code[:coupon] &&
promo_code[:coupon][:id] promo_code[:coupon][:id]
invoice_params = { customer: customer[:id] }
if SiteSetting.discourse_subscriptions_enable_automatic_tax
invoice_params[:automatic_tax] = { enabled: true }
end
invoice = ::Stripe::Invoice.create(invoice_params)
invoice_item = invoice_item =
::Stripe::InvoiceItem.create( ::Stripe::InvoiceItem.create(
customer: customer[:id], customer: customer[:id],
price: params[:plan], price: params[:plan],
discounts: [{ coupon: coupon_id }], discounts: [{ coupon: coupon_id }],
invoice: invoice[:id],
) )
invoice = ::Stripe::Invoice.create(customer: customer[:id])
transaction = ::Stripe::Invoice.finalize_invoice(invoice[:id]) transaction = ::Stripe::Invoice.finalize_invoice(invoice[:id])
payment_intent = retrieve_payment_intent(transaction[:id]) if transaction[:status] == payment_intent = retrieve_payment_intent(transaction[:id]) if transaction[:status] ==
"open" "open"
if payment_intent.nil?
return(
render_json_error I18n.t("js.discourse_subscriptions.subscribe.transaction_error")
)
end
transaction = ::Stripe::Invoice.pay(invoice[:id]) if payment_intent[:status] == transaction = ::Stripe::Invoice.pay(invoice[:id]) if payment_intent[:status] ==
"successful" "successful"
end end
@@ -167,11 +137,7 @@ module DiscourseSubscriptions
) )
if transaction[:object] == "subscription" if transaction[:object] == "subscription"
Subscription.create( Subscription.create(customer_id: customer.id, external_id: transaction[:id])
customer_id: customer.id,
external_id: transaction[:id],
status: transaction[:status],
)
end end
end end
@@ -190,17 +156,7 @@ module DiscourseSubscriptions
def current_user_products def current_user_products
return [] if current_user.nil? return [] if current_user.nil?
Customer Customer.select(:product_id).where(user_id: current_user.id).map { |c| c.product_id }.compact
.joins(:subscriptions)
.where(user_id: current_user.id)
.where(
Subscription.arel_table[:status].eq(nil).or(
Subscription.arel_table[:status].not_eq("canceled"),
),
)
.select(:product_id)
.distinct
.pluck(:product_id)
end end
def serialize_plans(plans) def serialize_plans(plans)
@@ -27,31 +27,10 @@ module DiscourseSubscriptions
payments = ::Stripe::PaymentIntent.list(customer: customer_id) payments = ::Stripe::PaymentIntent.list(customer: customer_id)
payments_from_invoices = payments_from_invoices =
payments[:data].select { |payment| invoice_ids.include?(payment[:invoice]) } payments[:data].select { |payment| invoice_ids.include?(payment[:invoice]) }
data = data | payments_from_invoices
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("Payments from invoices: #{payments_from_invoices}")
end
# Pricing table one-off purchases do not have invoices
payments_without_invoices =
payments[:data].select { |payment| payment[:invoice].nil? }
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("Payments without invoices: #{payments_without_invoices}")
end
data = data | payments_from_invoices | payments_without_invoices
end end
end end
if SiteSetting.discourse_subscriptions_pricing_table_enabled && current_user.email
related_guest_payments = fetch_guest_payments(current_user.email)
if SiteSetting.discourse_subscriptions_enable_verbose_logging
Rails.logger.warn("Related guest payments: #{related_guest_payments}")
end
data = data | related_guest_payments
end
data = data.sort_by { |pmt| pmt[:created] }.reverse data = data.sort_by { |pmt| pmt[:created] }.reverse
render_json_dump data render_json_dump data
@@ -63,13 +42,12 @@ module DiscourseSubscriptions
private private
def parse_invoices(all_invoices, product_ids) def parse_invoices(all_invoices, product_ids)
all_invoices[:data].select do |invoice| invoices_with_products =
invoice_lines = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data] all_invoices[:data].select do |invoice|
if invoice_lines invoice_lines = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data]
invoice_product_id = parse_invoice_lines(invoice_lines) invoice_product_id = parse_invoice_lines(invoice_lines)
product_ids.include?(invoice_product_id) product_ids.include?(invoice_product_id)
end end
end
end end
def parse_invoice_lines(invoice_lines) def parse_invoice_lines(invoice_lines)
@@ -79,40 +57,6 @@ module DiscourseSubscriptions
invoice_lines[:plan][:product] invoice_lines[:plan][:product]
invoice_product_id invoice_product_id
end end
def fetch_guest_payments(email)
guest_payments = []
starting_after = nil
begin
loop do
# Fetch charges in batches of 100, using pagination with starting_after
charges =
::Stripe::Charge.list(
limit: 100,
starting_after: starting_after,
expand: ["data.payment_intent"],
)
charges[:data].each do |charge|
# Check if the charge is associated with the given email and has no customer ID
if charge[:billing_details][:email] == email && charge[:customer].nil?
guest_payments << charge
end
end
# Check if there are more charges to fetch
break if charges[:data].count < 100
# Set starting_after to the last charge's ID for the next batch
starting_after = charges[:data].last[:id]
end
rescue ::Stripe::StripeError => e
render_json_error e.message
end
guest_payments
end
end end
end end
end end
@@ -15,7 +15,6 @@ module DiscourseSubscriptions
begin begin
customer = Customer.where(user_id: current_user.id) customer = Customer.where(user_id: current_user.id)
customer_ids = customer.map { |c| c.id } if customer customer_ids = customer.map { |c| c.id } if customer
stripe_customer_ids = customer.map { |c| c.customer_id }.uniq if customer
subscription_ids = subscription_ids =
Subscription.where("customer_id in (?)", customer_ids).pluck( Subscription.where("customer_id in (?)", customer_ids).pluck(
:external_id, :external_id,
@@ -24,25 +23,18 @@ module DiscourseSubscriptions
subscriptions = [] subscriptions = []
if subscription_ids if subscription_ids
prices = [] plans = ::Stripe::Price.list(expand: ["data.product"], limit: 100)
price_params = { limit: 100, expand: ["data.product"] }
loop do
response = ::Stripe::Price.list(price_params)
prices.concat(response[:data])
break unless response[:has_more]
price_params[:starting_after] = response[:data].last.id
end
all_subscriptions = []
stripe_customer_ids.each do |stripe_customer_id| customers =
customer_subscriptions = ::Stripe::Customer.list(email: current_user.email, expand: ["data.subscriptions"])
::Stripe::Subscription.list(customer: stripe_customer_id, status: "all")
all_subscriptions.concat(customer_subscriptions[:data]) subscriptions =
end customers[:data].map { |sub_customer| sub_customer[:subscriptions][:data] }.flatten(1)
subscriptions = subscriptions.select { |sub| subscription_ids.include?(sub[:id]) }
subscriptions = all_subscriptions.select { |sub| subscription_ids.include?(sub[:id]) }
subscriptions.map! do |subscription| subscriptions.map! do |subscription|
plan = prices.find { |p| p[:id] == subscription[:items][:data][0][:price][:id] } plan = plans[:data].find { |p| p[:id] == subscription[:items][:data][0][:price][:id] }
subscription.to_h.except!(:plan) subscription.to_h.except!(:plan)
subscription.to_h.merge(plan: plan, product: plan[:product].to_h.slice(:id, :name)) subscription.to_h.merge(plan: plan, product: plan[:product].to_h.slice(:id, :name))
end end
@@ -13,20 +13,3 @@ module DiscourseSubscriptions
end end
end end
end end
# == Schema Information
#
# Table name: discourse_subscriptions_customers
#
# id :bigint not null, primary key
# customer_id :string not null
# product_id :string
# user_id :bigint
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_discourse_subscriptions_customers_on_customer_id (customer_id)
# index_discourse_subscriptions_customers_on_user_id (user_id)
#
@@ -4,17 +4,3 @@ module DiscourseSubscriptions
class Product < ActiveRecord::Base class Product < ActiveRecord::Base
end end
end end
# == Schema Information
#
# Table name: discourse_subscriptions_products
#
# id :bigint not null, primary key
# external_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_discourse_subscriptions_products_on_external_id (external_id)
#
@@ -5,20 +5,3 @@ module DiscourseSubscriptions
belongs_to :customer belongs_to :customer
end end
end end
# == Schema Information
#
# Table name: discourse_subscriptions_subscriptions
#
# id :bigint not null, primary key
# customer_id :bigint not null
# external_id :string not null
# created_at :datetime not null
# updated_at :datetime not null
# status :string
#
# Indexes
#
# index_discourse_subscriptions_subscriptions_on_customer_id (customer_id)
# index_discourse_subscriptions_subscriptions_on_external_id (external_id)
#
@@ -53,14 +53,6 @@ module DiscourseSubscriptions
"S$" "S$"
when "zar" when "zar"
"R" "R"
when "chf"
"CHF"
when "pln"
"zł"
when "czk"
"Kč"
when "sek"
"kr"
else else
"$" "$"
end end
@@ -91,7 +91,7 @@ module DiscourseSubscriptions
params = { params = {
full_name: I18n.t("js.discourse_subscriptions.campaign.supporters"), full_name: I18n.t("js.discourse_subscriptions.campaign.supporters"),
title: I18n.t("js.discourse_subscriptions.campaign.supporter"), title: I18n.t("js.discourse_subscriptions.campaign.supporter"),
flair_icon: "circle-dollar-to-slot", flair_icon: "donate",
} }
group.update(params) group.update(params)
@@ -154,30 +154,26 @@ module DiscourseSubscriptions
all_invoices = all_invoices =
::Stripe::Invoice.list(limit: 100, starting_after: current_set[:last_record]) ::Stripe::Invoice.list(limit: 100, starting_after: current_set[:last_record])
if all_invoices[:data].present? current_set[:last_record] = all_invoices[:data].last[:id] if all_invoices[:data].present?
current_set[:last_record] = all_invoices[:data].last[:id] current_set[:has_more] = all_invoices[:has_more]
current_set[:has_more] = all_invoices[:has_more]
all_invoices[:data].each do |invoice| all_invoices[:data].each do |invoice|
customer_id = invoice[:customer] customer_id = invoice[:customer]
next if invoice[:paid] != true next if invoice[:paid] != true
line_item = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data] # Discourse only makes single-line item charges line_item = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data] # Discourse only makes single-line item charges
# check if non-subscription and that the plan is active # check if non-subscription and that the plan is active
if line_item && line_item[:plan] == nil && line_item[:price] && if line_item[:plan] == nil && line_item[:price] &&
line_item[:price][:recurring] == nil && line_item[:price][:active] == true line_item[:price][:recurring] == nil && line_item[:price][:active] == true
product_id = line_item[:price][:product] product_id = line_item[:price][:product]
if product_ids.include? product_id if product_ids.include? product_id
line_data = { line_data = {
customer_id: customer_id, customer_id: customer_id,
product_id: product_id, product_id: product_id,
price: line_item[:price][:unit_amount], price: line_item[:price][:unit_amount],
} }
one_time_payments << line_data one_time_payments << line_data
end
end end
end end
else
current_set[:has_more] = false
end end
end end
end end
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
{{#if shouldShow}}
<div
class="campaign-banner"
style={{html-safe (concat "box-shadow: 5px 5px #" dropShadowColor)}}
>
<DButton @icon="times" @action={{this.dismissBanner}} class="close" />
<div class="campaign-banner-info" style={{html-safe this.bannerInfoStyle}}>
{{#if isGoalMet}}
<h2 class="campaign-banner-info-header">
{{i18n "discourse_subscriptions.campaign.success_title"}}
</h2>
<p class="campaign-banner-info-description">
{{i18n "discourse_subscriptions.campaign.success_body"}}
</p>
{{else}}
<h2 class="campaign-banner-info-header">
{{i18n "discourse_subscriptions.campaign.title"}}
</h2>
<p class="campaign-banner-info-description">
{{i18n "discourse_subscriptions.campaign.body"}}
</p>
{{#if product}}
<LinkTo
@route="subscribe.show"
@model={{product}}
@disabled={{product.subscribed}}
class="btn btn-primary campaign-banner-info-button"
>
{{d-icon "far-heart"}}
{{d-icon "heart" class="hover-heart"}}
{{i18n "discourse_subscriptions.campaign.button"}}
</LinkTo>
{{else}}
<LinkTo
@route="subscribe"
class="btn btn-primary campaign-banner-info-button"
>
{{d-icon "far-heart"}}
{{d-icon "heart" class="hover-heart"}}
{{i18n "discourse_subscriptions.campaign.button"}}
</LinkTo>
{{/if}}
{{/if}}
</div>
<div class="campaign-banner-progress">
{{#if isGoalMet}}
<div class="fireworks">
<div class="before"></div>
<div class="after"></div>
</div>
<div class="campaign-banner-progress-success"></div>
{{#if subscriberGoal}}
<p class="campaign-banner-progress-description">
{{html-safe
(i18n
"discourse_subscriptions.campaign.goal_comparison"
current=subscribers
goal=goalTarget
)
}}
{{i18n "discourse_subscriptions.campaign.subscribers"}}
</p>
{{else}}
<p class="campaign-banner-progress-description">
{{html-safe
(i18n
"discourse_subscriptions.campaign.goal_comparison"
current=(format-currency currency amountRaised)
goal=(format-currency currency goalTarget)
)
}}
{{i18n "discourse_subscriptions.campaign.raised"}}
</p>
{{#if showContributors}}
<ConditionalLoadingSpinner @condition={{loading}} @size="small">
<div class="campaign-banner-progress-users">
<p class="campaign-banner-progress-users-title">
<strong>
{{i18n
"discourse_subscriptions.campaign.recent_contributors"
}}
</strong>
</p>
<div class="campaign-banner-progress-users-avatars">
{{#each contributors as |contributor|}}
{{avatar
contributor
avatarTemplatePath="avatar_template"
usernamePath="username"
namePath="name"
imageSize="small"
}}
{{/each}}
</div>
</div>
</ConditionalLoadingSpinner>
{{/if}}
{{/if}}
{{else}}
{{#if subscriberGoal}}
<progress
class="campaign-banner-progress-bar"
value={{subscribers}}
max={{siteSettings.discourse_subscriptions_campaign_goal}}
></progress>
<p class="campaign-banner-progress-description">
{{html-safe
(i18n
"discourse_subscriptions.campaign.goal_comparison"
current=subscribers
goal=goalTarget
)
}}
{{i18n "discourse_subscriptions.campaign.subscribers"}}
</p>
{{else}}
<progress
class="campaign-banner-progress-bar"
value={{amountRaised}}
max={{siteSettings.discourse_subscriptions_campaign_goal}}
></progress>
<p class="campaign-banner-progress-description">
{{html-safe
(i18n
"discourse_subscriptions.campaign.goal_comparison"
current=(format-currency currency amountRaised)
goal=(format-currency currency goalTarget)
)
}}
{{i18n "discourse_subscriptions.campaign.raised"}}
</p>
{{/if}}
{{#if showContributors}}
<ConditionalLoadingSpinner @condition={{loading}} @size="small">
<div class="campaign-banner-progress-users">
<p class="campaign-banner-progress-users-title">
<strong>
{{i18n
"discourse_subscriptions.campaign.recent_contributors"
}}
</strong>
</p>
<div class="campaign-banner-progress-users-avatars">
{{#each contributors as |contributor|}}
{{avatar
contributor
avatarTemplatePath="avatar_template"
usernamePath="username"
namePath="name"
imageSize="small"
}}
{{/each}}
</div>
</div>
</ConditionalLoadingSpinner>
{{/if}}
{{/if}}
</div>
</div>
{{/if}}
@@ -0,0 +1,187 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { equal } from "@ember/object/computed";
import { later } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { setting } from "discourse/lib/computed";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
const SIDEBAR_BODY_CLASS = "subscription-campaign-sidebar";
export default Component.extend({
router: service(),
dismissed: false,
loading: false,
dropShadowColor: setting(
"discourse_subscriptions_campaign_banner_shadow_color"
),
backgroundImageUrl: setting(
"discourse_subscriptions_campaign_banner_bg_image"
),
isSidebar: equal(
"siteSettings.discourse_subscriptions_campaign_banner_location",
"Sidebar"
),
subscribers: setting("discourse_subscriptions_campaign_subscribers"),
subscriberGoal: equal(
"siteSettings.discourse_subscriptions_campaign_type",
"Subscribers"
),
currency: setting("discourse_subscriptions_currency"),
amountRaised: setting("discourse_subscriptions_campaign_amount_raised"),
goalTarget: setting("discourse_subscriptions_campaign_goal"),
product: setting("discourse_subscriptions_campaign_product"),
showContributors: setting(
"discourse_subscriptions_campaign_show_contributors"
),
classNameBindings: ["isGoalMet:goal-met"],
init() {
this._super(...arguments);
this.set("contributors", []);
// add background-image url to stylesheet
if (this.backgroundImageUrl) {
const backgroundUrl = `url(${this.backgroundImageUrl}`.replace(/\\/g, "");
if (
document.documentElement.style.getPropertyValue(
"--campaign-background-image"
) !== backgroundUrl
) {
document.documentElement.style.setProperty(
"--campaign-background-image",
backgroundUrl
);
}
}
if (this.currentUser && this.showContributors) {
return ajax("/subscriptions/contributors", { method: "get" }).then(
(result) => {
this.setProperties({
contributors: result,
loading: false,
});
}
);
}
},
didInsertElement() {
this._super(...arguments);
if (this.isSidebar && this.shouldShow && !this.site.mobileView) {
document.body.classList.add(SIDEBAR_BODY_CLASS);
} else {
document.body.classList.remove(SIDEBAR_BODY_CLASS);
}
// makes sure to only play animation once, & not repeat on reload
if (this.isGoalMet) {
const successAnimationKey = this.keyValueStore.get(
"campaign_success_animation"
);
if (!successAnimationKey) {
later(() => {
this.keyValueStore.set({
key: "campaign_success_animation",
value: Date.now(),
});
document.body.classList.add("success-animation-off");
}, 7000);
} else {
document.body.classList.add("success-animation-off");
}
}
},
willDestroyElement() {
this._super(...arguments);
document.body.classList.remove(SIDEBAR_BODY_CLASS);
},
@discourseComputed("backgroundImageUrl")
bannerInfoStyle(backgroundImageUrl) {
if (!backgroundImageUrl) {
return "";
}
return `background-image: linear-gradient(
0deg,
rgba(var(--secondary-rgb), 0.75) 0%,
rgba(var(--secondary-rgb), 0.75) 100%),
var(--campaign-background-image);
background-size: cover;
background-repeat: no-repeat;`;
},
@discourseComputed(
"router.currentRouteName",
"currentUser",
"siteSettings.discourse_subscriptions_campaign_enabled",
"visible"
)
shouldShow(currentRoute, currentUser, enabled, visible) {
// do not show on admin or subscriptions pages
const showOnRoute =
currentRoute !== "discovery.s" &&
!currentRoute.split(".")[0].includes("admin") &&
currentRoute.split(".")[0] !== "subscribe";
if (!this.site.show_campaign_banner) {
return false;
}
// make sure not to render above main container when inside a topic
if (
this.connectorName === "above-main-container" &&
currentRoute.includes("topic")
) {
return false;
}
return showOnRoute && currentUser && enabled && visible;
},
@observes("dismissed")
_updateBodyClasses() {
if (this.dismissed) {
document.body.classList.remove(SIDEBAR_BODY_CLASS);
}
},
@discourseComputed("dismissed")
visible(dismissed) {
const dismissedBannerKey = this.keyValueStore.get(
"dismissed_campaign_banner"
);
const threeMonths = 2628000000 * 3;
const bannerDismissedTime = new Date(dismissedBannerKey);
const now = Date.now();
return (
(!dismissedBannerKey || now - bannerDismissedTime > threeMonths) &&
!dismissed
);
},
@discourseComputed
isGoalMet() {
const currentVolume = this.subscriberGoal
? this.subscribers
: this.amountRaised;
return currentVolume >= this.goalTarget;
},
@action
dismissBanner() {
this.set("dismissed", true);
this.keyValueStore.set({
key: "dismissed_campaign_banner",
value: Date.now(),
});
},
});
@@ -1,92 +0,0 @@
import Component, { Input } from "@ember/component";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import DButton from "discourse/components/d-button";
import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box";
export default class CreateCouponForm extends Component {
discountType = "amount";
discount = null;
promoCode = null;
active = false;
@discourseComputed
discountTypes() {
return [
{ id: "amount", name: "Amount" },
{ id: "percent", name: "Percent" },
];
}
@action
createNewCoupon() {
const createParams = {
promo: this.promoCode,
discount_type: this.discountType,
discount: this.discount,
active: this.active,
};
this.create(createParams);
}
@action
cancelCreate() {
this.cancel();
}
<template>
<div class="create-coupon-form">
<form class="form-horizontal">
<p>
<label for="promo_code">
{{i18n "discourse_subscriptions.admin.coupons.promo_code"}}
</label>
<Input @type="text" name="promo_code" @value={{this.promoCode}} />
</p>
<p>
<label for="amount">
{{i18n "discourse_subscriptions.admin.coupons.discount"}}
</label>
<ComboBox
@content={{this.discountTypes}}
@value={{this.discountType}}
@onChange={{fn (mut this.discountType)}}
/>
<Input
class="discount-amount"
@type="text"
name="amount"
@value={{this.discount}}
/>
</p>
<p>
<label for="active">
{{i18n "discourse_subscriptions.admin.coupons.active"}}
</label>
<Input @type="checkbox" name="active" @checked={{this.active}} />
</p>
</form>
<DButton
@action={{this.createNewCoupon}}
@label="discourse_subscriptions.admin.coupons.create"
@title="discourse_subscriptions.admin.coupons.create"
@icon="plus"
class="btn-primary btn btn-icon"
/>
<DButton
@action={{this.cancelCreate}}
label="cancel"
@title="cancel"
@icon="xmark"
class="btn btn-icon"
/>
</div>
</template>
}
@@ -0,0 +1,50 @@
<div class="create-coupon-form">
<form class="form-horizontal">
<p>
<label for="promo_code">
{{i18n "discourse_subscriptions.admin.coupons.promo_code"}}
</label>
<Input @type="text" name="promo_code" @value={{promoCode}} />
</p>
<p>
<label for="amount">
{{i18n "discourse_subscriptions.admin.coupons.discount"}}
</label>
<ComboBox
@content={{discountTypes}}
@value={{discountType}}
@onChange={{action (mut discountType)}}
/>
<Input
class="discount-amount"
@type="text"
name="amount"
@value={{discount}}
/>
</p>
<p>
<label for="active">
{{i18n "discourse_subscriptions.admin.coupons.active"}}
</label>
<Input @type="checkbox" name="active" @checked={{active}} />
</p>
</form>
<DButton
@action={{action "createNewCoupon"}}
@label="discourse_subscriptions.admin.coupons.create"
@title="discourse_subscriptions.admin.coupons.create"
@icon="plus"
class="btn-primary btn btn-icon"
/>
<DButton
@action={{action "cancelCreate"}}
label="cancel"
@title="cancel"
@icon="times"
class="btn btn-icon"
/>
</div>
@@ -0,0 +1,32 @@
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({
@discourseComputed
discountTypes() {
return [
{ id: "amount", name: "Amount" },
{ id: "percent", name: "Percent" },
];
},
discountType: "amount",
discount: null,
promoCode: null,
active: false,
actions: {
createNewCoupon() {
const createParams = {
promo: this.promoCode,
discount_type: this.discountType,
discount: this.discount,
active: this.active,
};
this.create(createParams);
},
cancelCreate() {
this.cancel();
},
},
});
@@ -1,16 +0,0 @@
import DButton from "discourse/components/d-button";
import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n";
const LoginRequired = <template>
<h3>{{i18n "discourse_subscriptions.subscribe.unauthenticated"}}</h3>
<DButton
@label="log_in"
@action={{routeAction "showLogin"}}
@icon="user"
class="btn btn-primary login-required subscriptions"
/>
</template>;
export default LoginRequired;
@@ -0,0 +1,8 @@
<h3>{{i18n "discourse_subscriptions.subscribe.unauthenticated"}}</h3>
<DButton
@label="log_in"
@action={{route-action "showLogin"}}
@icon="user"
class="btn btn-primary login-required subscriptions"
/>
@@ -4,7 +4,7 @@ import { Input } from "@ember/component";
import { fn, hash } from "@ember/helper"; import { fn, hash } from "@ember/helper";
import DButton from "discourse/components/d-button"; import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal"; import DModal from "discourse/components/d-modal";
import { i18n } from "discourse-i18n"; import i18n from "discourse-common/helpers/i18n";
export default class AdminCancelSubscription extends Component { export default class AdminCancelSubscription extends Component {
@tracked refund; @tracked refund;
@@ -25,13 +25,9 @@ export default class AdminCancelSubscription extends Component {
@label="yes_value" @label="yes_value"
@action={{fn @action={{fn
@model.cancelSubscription @model.cancelSubscription
(hash (hash [email protected] refund=this.refund)
[email protected]
refund=this.refund
closeModal=@closeModal
)
}} }}
@icon="xmark" @icon="times"
@isLoading={{@model.subscription.loading}} @isLoading={{@model.subscription.loading}}
class="btn-danger" class="btn-danger"
/> />
@@ -1,42 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import PaymentPlan from "./payment-plan";
export default class PaymentOptions extends Component {
@discourseComputed("plans")
orderedPlans(plans) {
if (plans) {
return plans.sort((a, b) => (a.unit_amount > b.unit_amount ? 1 : -1));
}
}
didInsertElement() {
super.didInsertElement(...arguments);
if (this.plans && this.plans.length === 1) {
this.set("selectedPlan", this.plans[0].id);
}
}
@action
clickPlan(plan) {
this.set("selectedPlan", plan.id);
}
<template>
<p>
{{i18n "discourse_subscriptions.plans.select"}}
</p>
<div class="subscribe-buttons">
{{#each this.orderedPlans as |plan|}}
<PaymentPlan
@plan={{plan}}
@selectedPlan={{this.selectedPlan}}
@clickPlan={{this.clickPlan}}
/>
{{/each}}
</div>
</template>
}
@@ -0,0 +1,13 @@
<p>
{{i18n "discourse_subscriptions.plans.select"}}
</p>
<div class="subscribe-buttons">
{{#each orderedPlans as |plan|}}
<PaymentPlan
@plan={{plan}}
@selectedPlan={{selectedPlan}}
@clickPlan={{action "clickPlan"}}
/>
{{/each}}
</div>
@@ -0,0 +1,23 @@
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({
@discourseComputed("plans")
orderedPlans(plans) {
if (plans) {
return plans.sort((a, b) => (a.unit_amount > b.unit_amount ? 1 : -1));
}
},
didInsertElement() {
this._super(...arguments);
if (this.plans && this.plans.length === 1) {
this.set("selectedPlan", this.plans[0].id);
}
},
actions: {
clickPlan(plan) {
this.set("selectedPlan", plan.id);
},
},
});
@@ -1,57 +0,0 @@
import Component from "@ember/component";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { tagName } from "@ember-decorators/component";
import DButton from "discourse/components/d-button";
import concatClass from "discourse/helpers/concat-class";
import discourseComputed from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import formatCurrency from "../helpers/format-currency";
const RECURRING = "recurring";
@tagName("")
export default class PaymentPlan extends Component {
@discourseComputed("selectedPlan")
selectedClass(planId) {
return planId === this.plan.id ? "btn-primary" : "";
}
@discourseComputed("plan.type")
recurringPlan(type) {
return type === RECURRING;
}
@action
planClick() {
this.clickPlan(this.plan);
return false;
}
<template>
<DButton
@action={{this.planClick}}
class={{concatClass
"btn-discourse-subscriptions-subscribe"
this.selectedClass
}}
>
<span class="interval">
{{#if this.recurringPlan}}
{{i18n
(concat
"discourse_subscriptions.plans.interval.adverb."
this.plan.recurring.interval
)
}}
{{else}}
{{i18n "discourse_subscriptions.one_time_payment"}}
{{/if}}
</span>
<span class="amount">
{{formatCurrency this.plan.currency this.plan.amountDollars}}
</span>
</DButton>
</template>
}
@@ -0,0 +1,21 @@
<DButton
@action={{action "planClick"}}
class={{concat-class "btn-discourse-subscriptions-subscribe" selectedClass}}
>
<span class="interval">
{{#if this.recurringPlan}}
{{i18n
(concat
"discourse_subscriptions.plans.interval.adverb."
this.plan.recurring.interval
)
}}
{{else}}
{{i18n "discourse_subscriptions.one_time_payment"}}
{{/if}}
</span>
<span class="amount">
{{format-currency this.plan.currency this.plan.amountDollars}}
</span>
</DButton>

Some files were not shown because too many files have changed in this diff Show More