diff --git a/app/controllers/controllers.rb b/app/controllers/controllers.rb new file mode 100644 index 0000000..d11f590 --- /dev/null +++ b/app/controllers/controllers.rb @@ -0,0 +1,2 @@ +load File.expand_path('../discourse_donations/charges_controller.rb', __FILE__) +load File.expand_path('../discourse_donations/checkout_controller.rb', __FILE__) diff --git a/app/controllers/discourse_donations/charges_controller.rb b/app/controllers/discourse_donations/charges_controller.rb index 7563474..017be3f 100644 --- a/app/controllers/discourse_donations/charges_controller.rb +++ b/app/controllers/discourse_donations/charges_controller.rb @@ -1,10 +1,24 @@ -require_dependency 'discourse' - module DiscourseDonations - class ChargesController < ApplicationController - + class ChargesController < ::ApplicationController skip_before_action :verify_authenticity_token, only: [:create] - skip_before_action :check_xhr + + before_action :ensure_logged_in, only: [:cancel_subscription] + before_action :set_user, only: [:index, :create] + before_action :set_email, only: [:index, :create, :cancel_subscription] + + def index + result = {} + + if current_user + stripe = DiscourseDonations::Stripe.new(secret_key, stripe_options) + + list_result = stripe.list(current_user, email: current_user.email) + + result = list_result if list_result.present? + end + + render json: success_json.merge(result) + end def create Rails.logger.info user_params.inspect @@ -12,7 +26,7 @@ module DiscourseDonations output = { 'messages' => [], 'rewards' => [] } if create_account - if !email.present? || !user_params[:username].present? + if !@email.present? || !user_params[:username].present? output['messages'] << I18n.t('login.missing_user_field') end if user_params[:password] && user_params[:password].length > User.max_password_length @@ -28,19 +42,36 @@ module DiscourseDonations end Rails.logger.debug "Creating a Stripe payment" - payment = DiscourseDonations::Stripe.new(secret_key, stripe_options) + stripe = DiscourseDonations::Stripe.new(secret_key, stripe_options) + result = {} begin Rails.logger.debug "Creating a Stripe charge for #{user_params[:amount]}" - charge_params = [user_params[:stripeToken], user_params[:amount]] + opts = { + email: @email, + token: user_params[:stripeToken], + amount: user_params[:amount] + } - if user - charge_params.unshift(user, user.email) + if user_params[:type] === 'once' + result[:charge] = stripe.charge(@user, opts) else - charge_params.unshift(nil, email) + opts[:type] = user_params[:type] + + subscription = stripe.subscribe(@user, opts) + + if subscription && subscription['id'] + invoices = stripe.invoices_for_subscription(@user, + email: opts[:email], + subscription_id: subscription['id'] + ) + end + + result[:subscription] = {} + result[:subscription][:subscription] = subscription if subscription + result[:subscription][:invoices] = invoices if invoices end - charge = payment.charge(*charge_params) rescue ::Stripe::CardError => e err = e.json_body[:error] @@ -52,13 +83,24 @@ module DiscourseDonations render(json: output) && (return) end - if charge['paid'] == true - output['messages'] << I18n.l(Time.now(), format: :long) + ': ' + I18n.t('donations.payment.success') + if (result[:charge] && result[:charge]['paid'] == true) || + (result[:subscription] && result[:subscription][:subscription] && + result[:subscription][:subscription]['status'] === 'active') + + output['messages'] << I18n.t('donations.payment.success') + + if (result[:charge] && result[:charge]['receipt_number']) || + (result[:subscription] && result[:subscription][:invoices].first['receipt_number']) + output['messages'] << " #{I18n.t('donations.payment.receipt_sent', email: @email)}" + end + + output['charge'] = result[:charge] if result[:charge] + output['subscription'] = result[:subscription] if result[:subscription] output['rewards'] << { type: :group, name: group_name } if group_name output['rewards'] << { type: :badge, name: badge_name } if badge_name - if create_account && email.present? + if create_account && @email.present? args = user_params.to_h.slice(:email, :username, :password, :name).merge(rewards: output['rewards']) Jobs.enqueue(:donation_user, args) end @@ -67,6 +109,20 @@ module DiscourseDonations render json: output end + def cancel_subscription + params.require(:subscription_id) + + stripe = DiscourseDonations::Stripe.new(secret_key, stripe_options) + + result = stripe.cancel_subscription(params[:subscription_id]) + + if result[:success] + render json: success_json.merge(subscription: result[:subscription]) + else + render json: failed_json.merge(message: result[:message]) + end + end + private def create_account @@ -97,19 +153,31 @@ module DiscourseDonations end def user_params - params.permit(:user_id, :name, :username, :email, :password, :stripeToken, :amount, :create_account) + params.permit(:user_id, :name, :username, :email, :password, :stripeToken, :type, :amount, :create_account) end - def email - user_params[:email] || user.try(:email) - end + def set_user + user = current_user - def user - if user_params[:user_id] - User.find(user_params[:user_id]) - else - current_user + if user_params[:user_id].present? + if record = User.find_by(user_params[:user_id]) + user = record + end end + + @user = user + end + + def set_email + email = nil + + if user_params[:email].present? + email = user_params[:email] + elsif @user + email = @user.try(:email) + end + + @email = email end end end diff --git a/app/controllers/discourse_donations/payments_controller.rb b/app/controllers/discourse_donations/payments_controller.rb deleted file mode 100644 index e4a6c27..0000000 --- a/app/controllers/discourse_donations/payments_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -module DiscourseDonations - class PaymentsController < ApplicationController - def index - render json: {} - end - - def show - render json: {} - end - end -end diff --git a/app/helpers/discourse_donations/application_helper.rb b/app/helpers/discourse_donations/application_helper.rb deleted file mode 100644 index 9009142..0000000 --- a/app/helpers/discourse_donations/application_helper.rb +++ /dev/null @@ -1,5 +0,0 @@ - -module DiscourseDonations - module ApplicationHelper - end -end diff --git a/app/services/discourse_donations/stripe.rb b/app/services/discourse_donations/stripe.rb index 936fcb7..cf7288e 100644 --- a/app/services/discourse_donations/stripe.rb +++ b/app/services/discourse_donations/stripe.rb @@ -9,54 +9,242 @@ module DiscourseDonations end def checkoutCharge(user = nil, email, token, amount) - customer = customer(user, email, token) + customer = customer(user, + email: email, + source: token, + create: true + ) + + return if !customer + charge = ::Stripe::Charge.create( customer: customer.id, amount: amount, description: @description, currency: @currency ) + charge end - def charge(user = nil, email, token, amount) - customer = customer(user, email, token) + def charge(user = nil, opts) + customer = customer(user, + email: opts[:email], + source: opts[:token], + create: true + ) + + return if !customer + @charge = ::Stripe::Charge.create( customer: customer.id, - amount: amount, - description: description, - currency: currency + amount: opts[:amount], + description: @description, + currency: @currency, + receipt_email: customer.email ) + @charge end - def subscribe(user = nil, email, opts) - customer = customer(user, email, opts[:stripeToken]) - @subscription = ::Stripe::Subscription.create( - customer: customer.id, - plan: opts[:plan] + def subscribe(user = nil, opts) + customer = customer(user, + email: opts[:email], + source: opts[:token], + create: true + ) + + return if !customer + + type = opts[:type] + amount = opts[:amount] + + plans = ::Stripe::Plan.list + plan_id = create_plan_id(type, amount) + + unless plans.data && plans.data.any? { |p| p['id'] === plan_id } + result = create_plan(type, amount) + + plan_id = result['id'] + end + + ::Stripe::Subscription.create( + customer: customer.id, + items: [{ + plan: plan_id + }] ) - @subscription end - def customer(user, email, source) - if user && user.stripe_customer_id - ::Stripe::Customer.retrieve(user.stripe_customer_id) - else - customer = ::Stripe::Customer.create( - email: email, - source: source + def list(user, opts = {}) + customer = customer(user, opts) + + return if !customer + + result = { customer: customer } + + raw_invoices = ::Stripe::Invoice.list(customer: customer.id) + raw_invoices = raw_invoices.is_a?(Object) ? raw_invoices['data'] : [] + + raw_charges = ::Stripe::Charge.list(customer: customer.id) + raw_charges = raw_charges.is_a?(Object) ? raw_charges['data'] : [] + + if raw_invoices.any? + raw_subscriptions = ::Stripe::Subscription.list(customer: customer.id, status: 'all') + raw_subscriptions = raw_subscriptions.is_a?(Object) ? raw_subscriptions['data'] : [] + + if raw_subscriptions.any? + subscriptions = [] + + raw_subscriptions.each do |subscription| + invoices = raw_invoices.select do |invoice| + invoice['subscription'] === subscription['id'] + end + + subscriptions.push( + subscription: subscription, + invoices: invoices + ) + end + + result[:subscriptions] = subscriptions + end + + ## filter out any charges related to subscriptions + raw_invoice_ids = raw_invoices.map { |i| i['id'] } + raw_charges = raw_charges.select { |c| raw_invoice_ids.exclude?(c['invoice']) } + end + + if raw_charges.any? + result[:charges] = raw_charges + end + + result + end + + def invoices_for_subscription(user, opts) + customer = customer(user, + email: opts[:email] + ) + + invoices = [] + + if customer + result = ::Stripe::Invoice.list( + customer: customer.id, + subscription: opts[:subscription_id] ) + + invoices = result['data'] if result['data'] + end + + invoices + end + + def cancel_subscription(subscription_id) + if subscription = ::Stripe::Subscription.retrieve(subscription_id) + result = subscription.delete + + if result['status'] === 'canceled' + { success: true, subscription: subscription } + else + { success: false, message: I18n.t('donations.subscription.error.not_cancelled') } + end + else + { success: false, message: I18n.t('donations.subscription.error.not_found') } + end + end + + def customer(user, opts = {}) + customer = nil + + if user && user.stripe_customer_id + begin + customer = ::Stripe::Customer.retrieve(user.stripe_customer_id) + rescue ::Stripe::StripeError => e + user.custom_fields['stripe_customer_id'] = nil + user.save_custom_fields(true) + customer = nil + end + end + + if !customer && opts[:email] + begin + customers = ::Stripe::Customer.list(email: opts[:email]) + + if customers && customers['data'] + customer = customers['data'].first if customers['data'].any? + end + + if customer && user + user.custom_fields['stripe_customer_id'] = customer.id + user.save_custom_fields(true) + end + rescue ::Stripe::StripeError => e + customer = nil + end + end + + if !customer && opts[:create] + customer = ::Stripe::Customer.create( + email: opts[:email], + source: opts[:source] + ) + if user user.custom_fields['stripe_customer_id'] = customer.id user.save_custom_fields(true) end - customer end + + customer end def successful? @charge[:paid] end + + def create_plan(type, amount) + id = create_plan_id(type, amount) + nickname = id.gsub(/_/, ' ').titleize + + products = ::Stripe::Product.list(type: 'service') + + if products['data'] && products['data'].any? { |p| p['id'] === product_id } + product = product_id + else + result = create_product + product = result['id'] + end + + ::Stripe::Plan.create( + id: id, + nickname: nickname, + interval: type, + currency: @currency, + product: product, + amount: amount.to_i + ) + end + + def create_product + ::Stripe::Product.create( + id: product_id, + name: product_name, + type: 'service' + ) + end + + def product_id + @product_id ||= "#{SiteSetting.title}_recurring_donation".freeze + end + + def product_name + @product_name ||= I18n.t('donations.recurring', site_title: SiteSetting.title) + end + + def create_plan_id(type, amount) + "discourse_donation_recurring_#{type}_#{amount}".freeze + end end end diff --git a/app/services/services.rb b/app/services/services.rb new file mode 100644 index 0000000..8e8aabc --- /dev/null +++ b/app/services/services.rb @@ -0,0 +1,2 @@ +load File.expand_path('../discourse_donations/rewards.rb', __FILE__) +load File.expand_path('../discourse_donations/stripe.rb', __FILE__) diff --git a/assets/javascripts/discourse/components/donation-list.js.es6 b/assets/javascripts/discourse/components/donation-list.js.es6 new file mode 100644 index 0000000..3b9b4da --- /dev/null +++ b/assets/javascripts/discourse/components/donation-list.js.es6 @@ -0,0 +1,8 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Component.extend({ + classNames: 'donation-list', + hasSubscriptions: Ember.computed.notEmpty('subscriptions'), + hasCharges: Ember.computed.notEmpty('charges') +}) diff --git a/assets/javascripts/discourse/components/donation-row.js.es6 b/assets/javascripts/discourse/components/donation-row.js.es6 new file mode 100644 index 0000000..56533f5 --- /dev/null +++ b/assets/javascripts/discourse/components/donation-row.js.es6 @@ -0,0 +1,96 @@ +import { ajax } from 'discourse/lib/ajax'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { formatAnchor, formatAmount } from '../lib/donation-utilities'; +import { default as computed, observes, on } from 'ember-addons/ember-computed-decorators'; +import showModal from "discourse/lib/show-modal"; + +export default Ember.Component.extend({ + classNameBindings: [':donation-row', 'canceled', 'updating'], + includePrefix: Ember.computed.or('invoice', 'charge'), + canceled: Ember.computed.equal('subscription.status', 'canceled'), + + @computed('subscription', 'invoice', 'charge', 'customer') + data(subscription, invoice, charge, customer) { + if (subscription) { + return $.extend({}, subscription.plan, { + anchor: subscription.billing_cycle_anchor + }); + } else if (invoice) { + let receiptSent = false; + + if (invoice.receipt_number && customer.email) { + receiptSent = true; + } + + return $.extend({}, invoice.lines.data[0], { + anchor: invoice.date, + invoiceLink: invoice.invoice_pdf, + receiptSent + }); + } else if (charge) { + let receiptSent = false; + + if (charge.receipt_number && charge.receipt_email) { + receiptSent = true; + } + + return $.extend({}, charge, { + anchor: charge.created, + receiptSent + }); + } + }, + + @computed('data.currency') + currency(currency) { + return currency ? currency.toUpperCase() : null; + }, + + @computed('data.amount', 'currency') + amount(amount, currency) { + return formatAmount(amount, currency); + }, + + @computed('data.interval') + interval(interval) { + return interval || 'once'; + }, + + @computed('data.anchor', 'interval') + period(anchor, interval) { + return I18n.t(`discourse_donations.period.${interval}`, { + anchor: formatAnchor(interval, moment.unix(anchor)) + }) + }, + + cancelSubscription() { + const subscriptionId = this.get('subscription.id'); + this.set('updating', true); + + ajax('/donate/charges/cancel-subscription', { + data: { + subscription_id: subscriptionId + }, + method: 'put' + }).then(result => { + if (result.success) { + this.set('subscription', result.subscription); + } + }).catch(popupAjaxError).finally(() => { + this.set('updating', false); + }); + }, + + actions: { + cancelSubscription() { + showModal('cancel-subscription', { + model: { + currency: this.get('currency'), + amount: this.get('amount'), + period: this.get('period'), + confirm: () => this.cancelSubscription() + } + }); + } + } +}) diff --git a/assets/javascripts/discourse/components/stripe-card.js.es6 b/assets/javascripts/discourse/components/stripe-card.js.es6 index ee83b52..2708ca1 100644 --- a/assets/javascripts/discourse/components/stripe-card.js.es6 +++ b/assets/javascripts/discourse/components/stripe-card.js.es6 @@ -1,21 +1,47 @@ import { ajax } from 'discourse/lib/ajax'; -import { getRegister } from 'discourse-common/lib/get-owner'; +import { formatAnchor, zeroDecimalCurrencies } from '../lib/donation-utilities'; import { default as computed } from 'ember-addons/ember-computed-decorators'; +import { emailValid } from "discourse/lib/utilities"; export default Ember.Component.extend({ result: [], - amount: 1, stripe: null, transactionInProgress: null, settings: null, showTransactionFeeDescription: false, + includeTransactionFee: true, init() { this._super(); - this.set('anon', (!Discourse.User.current())); - this.set('settings', getRegister(this).lookup('site-settings:main')); - this.set('create_accounts', this.get('anon') && this.get('settings').discourse_donations_enable_create_accounts); - this.set('stripe', Stripe(this.get('settings').discourse_donations_public_key)); + const user = this.get('currentUser'); + const settings = Discourse.SiteSettings; + + this.set('create_accounts', !user && settings.discourse_donations_enable_create_accounts); + this.set('stripe', Stripe(settings.discourse_donations_public_key)); + + const types = settings.discourse_donations_types.split('|') || []; + const amounts = this.get('donateAmounts'); + + this.setProperties({ + types, + type: types[0], + amount: amounts[0].value + }); + }, + + @computed('types') + donationTypes(types) { + return types.map((type) => { + return { + id: type, + name: I18n.t(`discourse_donations.types.${type}`) + }; + }); + }, + + @computed('type') + period(type) { + return I18n.t(`discourse_donations.period.${type}`, { anchor: formatAnchor(type) }); }, @computed @@ -36,9 +62,23 @@ export default Ember.Component.extend({ @computed('stripe') card(stripe) { let elements = stripe.elements(); - return elements.create('card', { - hidePostalCode: !this.get('settings').discourse_donations_zip_code + let card = elements.create('card', { + hidePostalCode: !Discourse.SiteSettings.discourse_donations_zip_code }); + + card.addEventListener('change', (event) => { + if (event.error) { + this.set('stripeError', event.error.message); + } else { + this.set('stripeError', ''); + } + + if (event.elementType === 'card' && event.complete) { + this.set('stripeReady', true); + } + }); + + return card; }, @computed('amount') @@ -55,9 +95,48 @@ export default Ember.Component.extend({ return amount; }, + @computed('email') + emailValid(email) { + return emailValid(email); + }, + + @computed('email', 'emailValid') + showEmailError(email, emailValid) { + return email && email.length > 3 && !emailValid; + }, + + @computed('currentUser', 'emailValid') + userReady(currentUser, emailValid) { + return currentUser || emailValid; + }, + + @computed('userReady', 'stripeReady') + formIncomplete(userReady, stripeReady) { + return !userReady || !stripeReady; + }, + + @computed('transactionInProgress', 'formIncomplete') + disableSubmit(transactionInProgress, formIncomplete) { + return transactionInProgress || formIncomplete; + }, + didInsertElement() { this._super(); this.get('card').mount('#card-element'); + Ember.$(document).on('click', Ember.run.bind(this, this.documentClick)); + }, + + willDestroyElement() { + Ember.$(document).off('click', Ember.run.bind(this, this.documentClick)); + }, + + documentClick(e) { + let $element = this.$('.transaction-fee-description'); + let $target = $(e.target); + if ($target.closest($element).length < 1 && + this._state !== 'destroying') { + this.set('showTransactionFeeDescription', false); + } }, setSuccess() { @@ -79,27 +158,57 @@ export default Ember.Component.extend({ submitStripeCard() { let self = this; - self.set('transactionInProgress', true); + this.set('transactionInProgress', true); + this.get('stripe').createToken(this.get('card')).then(data => { self.set('result', []); if (data.error) { - self.set('result', data.error.message); + this.setProperties({ + stripeError: data.error.message, + stripeReady: false + }); self.endTranscation(); } else { - const transactionFeeEnabled = Discourse.SiteSettings.discourse_donations_enable_transaction_fee; - const amount = transactionFeeEnabled ? this.get('totalAmount') : this.get('amount'); + const settings = Discourse.SiteSettings; + + const transactionFeeEnabled = settings.discourse_donations_enable_transaction_fee; + let amount = transactionFeeEnabled ? this.get('totalAmount') : this.get('amount'); + + if (zeroDecimalCurrencies.indexOf(settings.discourse_donations_currency) === -1) { + amount = amount * 100; + } + let params = { stripeToken: data.token.id, - amount: amount * 100, + type: self.get('type'), + amount, email: self.get('email'), username: self.get('username'), create_account: self.get('create_accounts') }; if(!self.get('paymentSuccess')) { - ajax('/charges', { data: params, method: 'post' }).then(d => { - self.concatMessages(d.messages); + ajax('/donate/charges', { + data: params, + method: 'post' + }).then(result => { + if (result.subscription) { + let subscription = $.extend({}, result.subscription, { + new: true + }); + this.get('subscriptions').unshiftObject(subscription); + } + + if (result.charge) { + let charge = $.extend({}, result.charge, { + new: true + }); + this.get('charges').unshiftObject(charge); + } + + self.concatMessages(result.messages); + self.endTranscation(); }); } diff --git a/assets/javascripts/discourse/controllers/cancel-subscription.js.es6 b/assets/javascripts/discourse/controllers/cancel-subscription.js.es6 new file mode 100644 index 0000000..57a6565 --- /dev/null +++ b/assets/javascripts/discourse/controllers/cancel-subscription.js.es6 @@ -0,0 +1,12 @@ +export default Ember.Controller.extend({ + actions: { + confirm() { + this.get('model.confirm')(); + this.send('closeModal'); + }, + + cancel() { + this.send('closeModal'); + } + } +}) diff --git a/assets/javascripts/discourse/controllers/donate.js.es6 b/assets/javascripts/discourse/controllers/donate.js.es6 new file mode 100644 index 0000000..2073af0 --- /dev/null +++ b/assets/javascripts/discourse/controllers/donate.js.es6 @@ -0,0 +1,53 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { ajax } from 'discourse/lib/ajax'; +import { getOwner } from 'discourse-common/lib/get-owner'; + +export default Ember.Controller.extend({ + loadingDonations: false, + loadDonationsDisabled: Ember.computed.not('emailVaild'), + + @computed('charges.[]', 'subscriptions.[]') + hasDonations(charges, subscriptions) { + return (charges && charges.length > 0) || + (subscriptions && subscriptions.length > 0); + }, + + @computed('email') + emailVaild(email) { + return emailValid(email); + }, + + actions: { + loadDonations() { + let email = this.get('email'); + + this.set('loadingDonations', true); + + ajax('/donate/charges', { + data: { email }, + type: 'GET' + }).then((result) => { + this.setProperties({ + charges: Ember.A(result.charges), + subscriptions: Ember.A(result.subscriptions), + customer: result.customer + }); + }).catch(popupAjaxError).finally(() => { + this.setProperties({ + loadingDonations: false, + hasEmailResult: true + }); + + Ember.run.later(() => { + this.set('hasEmailResult', false); + }, 6000) + }) + }, + + showLogin() { + const controller = getOwner(this).lookup('route:application'); + controller.send('showLogin'); + } + } +}) diff --git a/assets/javascripts/discourse/lib/donation-utilities.js.es6 b/assets/javascripts/discourse/lib/donation-utilities.js.es6 new file mode 100644 index 0000000..6f82bf2 --- /dev/null +++ b/assets/javascripts/discourse/lib/donation-utilities.js.es6 @@ -0,0 +1,31 @@ +const formatAnchor = function(type = null, time = moment()) { + let format; + + switch(type) { + case 'once': + format = 'Do MMMM YYYY'; + break; + case 'week': + format = 'dddd'; + break; + case 'month': + format = 'Do'; + break; + case 'year': + format = 'MMMM D'; + break; + default: + format = 'dddd'; + } + + return moment(time).format(format); +} + +const zeroDecimalCurrencies = ['MGA', 'BIF', 'CLP', 'PYG', 'DFJ', 'RWF', 'GNF', 'UGX', 'JPY', 'VND', 'VUV', 'XAF', 'KMF', 'KRW', 'XOF', 'XPF']; + +const formatAmount = function(amount, currency) { + let zeroDecimal = zeroDecimalCurrencies.indexOf(currency) > -1; + return zeroDecimal ? amount : (amount / 100).toFixed(2); +} + +export { formatAnchor, formatAmount, zeroDecimalCurrencies } diff --git a/assets/javascripts/discourse/routes/donate.js.es6 b/assets/javascripts/discourse/routes/donate.js.es6 new file mode 100644 index 0000000..04d7994 --- /dev/null +++ b/assets/javascripts/discourse/routes/donate.js.es6 @@ -0,0 +1,29 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import { ajax } from 'discourse/lib/ajax'; + +export default DiscourseRoute.extend({ + setupController(controller) { + let charges = []; + let subscriptions = []; + let customer = {}; + + controller.set('loadingDonations', true); + + ajax('/donate/charges').then((result) => { + if (result) { + charges = result.charges; + subscriptions = result.subscriptions; + customer = result.customer; + } + + controller.setProperties({ + charges: Ember.A(charges), + subscriptions: Ember.A(subscriptions), + customer + }); + }).catch(popupAjaxError).finally(() => { + controller.set('loadingDonations', false); + }) + } +}); diff --git a/assets/javascripts/discourse/templates/components/donation-list.hbs b/assets/javascripts/discourse/templates/components/donation-list.hbs new file mode 100644 index 0000000..4a23964 --- /dev/null +++ b/assets/javascripts/discourse/templates/components/donation-list.hbs @@ -0,0 +1,28 @@ +{{#if hasSubscriptions}} +
+
{{i18n 'discourse_donations.donations.subscriptions'}}
+ +
+{{/if}} + +{{#if hasCharges}} +
+
{{i18n 'discourse_donations.donations.charges'}}
+ +
+{{/if}} diff --git a/assets/javascripts/discourse/templates/components/donation-row.hbs b/assets/javascripts/discourse/templates/components/donation-row.hbs new file mode 100644 index 0000000..d37abb4 --- /dev/null +++ b/assets/javascripts/discourse/templates/components/donation-row.hbs @@ -0,0 +1,39 @@ +{{#if includePrefix}} + {{i18n 'discourse_donations.invoice_prefix'}} +{{/if}} + +{{currency}} + +{{amount}} + +{{period}} + +{{#if invoice}} + ({{i18n 'discourse_donations.invoice'}}) +{{/if}} + +{{#if currentUser}} + {{#if subscription}} + {{#if updating}} + {{loading-spinner size='small'}} + {{else}} + {{#unless canceled}} + + {{i18n 'cancel'}} + + {{/unless}} + {{/if}} + {{/if}} +{{/if}} + +{{#if receiptSent}} + + {{i18n 'discourse_donations.receipt' email=customer.email}} +{{/if}} + +{{#if new}} + + {{d-icon 'circle'}} + {{i18n 'new_item'}} + +{{/if}} diff --git a/assets/javascripts/discourse/templates/components/stripe-card.hbs b/assets/javascripts/discourse/templates/components/stripe-card.hbs index d3fd676..e8ff719 100644 --- a/assets/javascripts/discourse/templates/components/stripe-card.hbs +++ b/assets/javascripts/discourse/templates/components/stripe-card.hbs @@ -1,9 +1,17 @@
+
+ +
+ {{combo-box content=donationTypes value=type}} +
+
{{combo-box valueAttribute="value" content=donateAmounts value=amount}} @@ -14,7 +22,7 @@
{{input type="checkbox" checked=includeTransactionFee}} - {{i18n 'discourse_donations.transaction_fee.label' transactionFee=transactionFee currency=settings.discourse_donations_currency}} + {{i18n 'discourse_donations.transaction_fee.label' transactionFee=transactionFee currency=siteSettings.discourse_donations_currency}}
{{d-icon 'info-circle'}} {{#if showTransactionFeeDescription}} @@ -25,27 +33,39 @@
+
- {{settings.discourse_donations_currency}} + {{siteSettings.discourse_donations_currency}} {{totalAmount}} + {{period}}
{{/if}}
-
+
+
+ {{#if stripeError}} +
{{stripeError}}
+ {{/if}} +
- {{#if anon}} + {{#unless currentUser}}
{{text-field value=email}} + {{#if showEmailError}} +
{{i18n 'user.email.invalid'}}
+ {{else}} +
{{i18n 'discourse_donations.email_instructions'}}
+ {{/if}}
@@ -71,11 +91,11 @@
{{/if}} - {{/if}} + {{/unless}}
- {{#d-button action="submitStripeCard" disabled=transactionInProgress class="btn btn-primary btn-payment"}} + {{#d-button action="submitStripeCard" disabled=disableSubmit class="btn btn-primary btn-payment"}} {{#if create_accounts}} {{i18n 'discourse_donations.submit_with_create_account'}} {{else}} diff --git a/assets/javascripts/discourse/templates/connectors/extra-nav-item/donate.hbs b/assets/javascripts/discourse/templates/connectors/extra-nav-item/donate.hbs index 1f57cc3..69bb4ec 100644 --- a/assets/javascripts/discourse/templates/connectors/extra-nav-item/donate.hbs +++ b/assets/javascripts/discourse/templates/connectors/extra-nav-item/donate.hbs @@ -1,5 +1,5 @@ {{#if siteSettings.discourse_donations_enabled}} - - {{i18n 'discourse_donations.nav_item'}} - + + {{i18n 'discourse_donations.nav_item'}} + {{/if}} diff --git a/assets/javascripts/discourse/templates/donate.hbs b/assets/javascripts/discourse/templates/donate.hbs index 0dccb22..c639b0c 100644 --- a/assets/javascripts/discourse/templates/donate.hbs +++ b/assets/javascripts/discourse/templates/donate.hbs @@ -1,7 +1,36 @@ -

{{i18n 'discourse_donations.title' site_name=siteSettings.title}}

+

{{i18n 'discourse_donations.title' site_name=siteSettings.title}}

+
{{cook-text siteSettings.discourse_donations_page_description}}
+
- {{stripe-card}} + {{stripe-card charges=charges subscriptions=subscriptions}} +
+ +
+

{{i18n 'discourse_donations.donations.title'}}

+ {{#if loadingDonations}} + {{i18n 'discourse_donations.donations.loading'}} + {{loading-spinner size='small'}} + {{else}} + {{#if currentUser}} + {{#if hasDonations}} + {{donation-list charges=charges subscriptions=subscriptions customer=customer}} + {{else}} + {{i18n 'discourse_donations.donations.none'}} + {{/if}} + {{else}} + {{#if hasDonations}} + {{donation-list charges=charges subscriptions=subscriptions customer=customer}} + {{else}} + {{#if hasEmailResult}} + {{i18n 'discourse_donations.donations.none_email' email=email}} + {{else}} + {{input value=email placeholder=(i18n 'email')}} + {{d-button action='loadDonations' label='discourse_donations.donations.load' disabled=loadDonationsDisabled}} + {{/if}} + {{/if}} + {{/if}} + {{/if}}
diff --git a/assets/javascripts/discourse/templates/modal/cancel-subscription.hbs b/assets/javascripts/discourse/templates/modal/cancel-subscription.hbs new file mode 100644 index 0000000..b67cb78 --- /dev/null +++ b/assets/javascripts/discourse/templates/modal/cancel-subscription.hbs @@ -0,0 +1,11 @@ +{{#d-modal-body title='discourse_donations.subscription.cancel.title'}} + {{i18n 'discourse_donations.subscription.cancel.description' site=siteSettings.title + currency=model.currency + amount=model.amount + period=model.period}} +{{/d-modal-body}} + + diff --git a/assets/stylesheets/discourse-donations.scss b/assets/stylesheets/discourse-donations.scss index dcbe614..a120f28 100644 --- a/assets/stylesheets/discourse-donations.scss +++ b/assets/stylesheets/discourse-donations.scss @@ -13,7 +13,26 @@ div.stripe-errors { } .donations-page-payment { - padding-top: 60px; + padding: 30px 0; + + #payment-form { + .control-label { + margin: 0 6.5px; + } + + .select-kit ul { + margin: 0; + } + + input[type="checkbox"] { + margin: 0; + } + + .error, .stripe-error { + margin-top: 5px; + color: $danger; + } + } } .transaction-fee-description { @@ -35,3 +54,57 @@ div.stripe-errors { width: 400px; z-index: 1; } + +.body-page .donations-page-donations { + margin-bottom: 20px; + + .donation-list { + .subscription-list, .charge-list { + margin-bottom: 10px; + + > ul { + margin: 10px 0; + list-style: none; + + .spinner { + height: 5px; + width: 5px; + } + } + } + + .underline { + border-bottom: 1px solid $primary-medium; + display: inline-block; + } + } +} + +.donation-row { + span { + line-height: 25px; + } + + &.canceled { + text-decoration: line-through; + } + + &.updating { + color: $primary-low; + } + + .new-flag { + color: $tertiary; + margin-left: 5px; + + .fa { + line-height: 16px; + font-size: 8px; + } + + > * { + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 975e9ba..69076a5 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -15,22 +15,52 @@ en: discourse_donations_enable_transaction_fee: "Give the user the option of including the Stripe transaction fee in their donation." discourse_donations_transaction_fee_fixed: "Fixed part of Stripe transaction fee (changes per region). See Stripe's pricing for your region and Stripe's explaination of passing fees onto customers." discourse_donations_transaction_fee_percent: "Percent part of Stripe transaction fee (changes per region). See Stripe's pricing for your region and Stripe's explaination of passing fees onto customers." - discourse_donations_amounts: "Donation amounts available to user" + discourse_donations_amounts: "Donation amounts available to user. First listed will be the default." discourse_donations_custom_amount: "Allow custom donation amount" + discourse_donations_types: "Donation types. First listed will be the default." errors: discourse_donations_amount_must_be_number: "Amounts must be numbers" js: discourse_donations: - title: Donate nav_item: Donate + title: "Make a Donation" amount: Amount - card: Credit or debit card - submit: Make Payment + card: Card + submit: Donate submit_with_create_account: Make Payment and Create Account + invoice: "invoice" + invoice_prefix: "You gave" + receipt: "Receipt sent to {{email}}." + subscription: + cancel: + title: "Cancel Recurring Donation" + description: > + Are you sure you want to cancel your recurring donation to {{site}} + of {{currency}} {{amount}} {{period}}? + email_instructions: "Required to send you a receipt. Not used for marketing." transaction_fee: label: "Include transaction fee of {{currency}} {{transactionFee}}" - description: "When you make a donation we get charged a transaciton fee. If you would like to help us out with this fee, check this box and it will be included in your donation." + description: "When you make a donation we get charged a transaction fee. If you would like to help us out with this fee, check this box and it will be included in your donation." total: "Total" messages: success: Thank you for your donation! + type: "Type" + types: + once: "Once" + week: "Weekly" + month: "Monthly" + year: "Yearly" + period: + once: "on {{anchor}}" + week: "every week on {{anchor}}" + month: "on the {{anchor}} of every month" + year: "every year on {{anchor}}" + donations: + title: "Your Donations" + load: "Load Donations" + loading: "Loading donations" + charges: "Once Off" + subscriptions: "Recurring" + none: "You haven't made a donation yet." + none_email: "There are no donations for {{email}}." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index dc63d40..c72bc8f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1,4 +1,11 @@ en: donations: + recurring: "%{site_title} Recurring Donation" payment: - success: 'Thank you. Your donation has been successful' + success: 'Thank you, your donation has been successful.' + receipt_sent: 'A receipt has been sent to %{email}.' + invoice_sent: 'An invoice has been sent to %{email}.' + subscription: + error: + not_found: "Subscription not found." + not_cancelled: "Subscription not cancelled." diff --git a/config/routes.rb b/config/routes.rb index eba16d8..b08fab8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ DiscourseDonations::Engine.routes.draw do - resources :charges, only: [:create] + get '/' => 'charges#index' + + resources :charges, only: [:index, :create] + put '/charges/cancel-subscription' => 'charges#cancel_subscription' + resources :checkout, only: [:create] - get 'users/:username/payments' => 'payments#show' - get 'donate' => 'payments#show' end diff --git a/config/settings.yml b/config/settings.yml index 8ba2e94..ac439b2 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -50,3 +50,12 @@ plugins: default: '1|2|5|10|20|50' regex: "^[0-9\\|]+$" regex_error: "site_settings.errors.discourse_donations_amount_must_be_number" + discourse_donations_types: + client: true + type: list + default: 'month|once' + choices: + - year + - month + - week + - once diff --git a/lib/discourse_donations/engine.rb b/lib/discourse_donations/engine.rb index c743474..d92e065 100644 --- a/lib/discourse_donations/engine.rb +++ b/lib/discourse_donations/engine.rb @@ -1,4 +1,3 @@ - module ::DiscourseDonations class Engine < ::Rails::Engine engine_name 'discourse-donations' diff --git a/plugin.rb b/plugin.rb index b6f6f56..514fd5e 100644 --- a/plugin.rb +++ b/plugin.rb @@ -2,12 +2,10 @@ # about: Integrates Stripe into Discourse to allow forum visitors to make donations # version: 1.11.1 # url: https://github.com/chrisbeach/discourse-donations -# authors: Rimian Perkins, Chris Beach +# authors: Rimian Perkins, Chris Beach, Angus McLeod gem 'stripe', '2.8.0' -load File.expand_path('../lib/discourse_donations/engine.rb', __FILE__) - register_asset "stylesheets/discourse-donations.scss" enabled_site_setting :discourse_donations_enabled @@ -17,19 +15,23 @@ register_html_builder('server:before-head-close') do end after_initialize do + load File.expand_path('../lib/discourse_donations/engine.rb', __FILE__) + load File.expand_path('../config/routes.rb', __FILE__) + load File.expand_path('../app/controllers/controllers.rb', __FILE__) load File.expand_path('../app/jobs/jobs.rb', __FILE__) + load File.expand_path('../app/services/services.rb', __FILE__) + + Discourse::Application.routes.append do + mount ::DiscourseDonations::Engine, at: 'donate' + end class ::User def stripe_customer_id if custom_fields['stripe_customer_id'] - custom_fields['stripe_customer_id'] + custom_fields['stripe_customer_id'].to_s else nil end end end end - -Discourse::Application.routes.prepend do - mount ::DiscourseDonations::Engine, at: '/' -end