Merge pull request #12 from chrisbeach/recurring_donations

Recurring donations and other fixes
This commit is contained in:
Angus McLeod 2018-06-28 14:14:26 +10:00 committed by GitHub
commit 4e9cc7d187
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 937 additions and 106 deletions

View File

@ -0,0 +1,2 @@
load File.expand_path('../discourse_donations/charges_controller.rb', __FILE__)
load File.expand_path('../discourse_donations/checkout_controller.rb', __FILE__)

View File

@ -1,10 +1,24 @@
require_dependency 'discourse'
module DiscourseDonations module DiscourseDonations
class ChargesController < ApplicationController class ChargesController < ::ApplicationController
skip_before_action :verify_authenticity_token, only: [:create] 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 def create
Rails.logger.info user_params.inspect Rails.logger.info user_params.inspect
@ -12,7 +26,7 @@ module DiscourseDonations
output = { 'messages' => [], 'rewards' => [] } output = { 'messages' => [], 'rewards' => [] }
if create_account 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') output['messages'] << I18n.t('login.missing_user_field')
end end
if user_params[:password] && user_params[:password].length > User.max_password_length if user_params[:password] && user_params[:password].length > User.max_password_length
@ -28,19 +42,36 @@ module DiscourseDonations
end end
Rails.logger.debug "Creating a Stripe payment" 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 begin
Rails.logger.debug "Creating a Stripe charge for #{user_params[:amount]}" 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 if user_params[:type] === 'once'
charge_params.unshift(user, user.email) result[:charge] = stripe.charge(@user, opts)
else 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 end
charge = payment.charge(*charge_params)
rescue ::Stripe::CardError => e rescue ::Stripe::CardError => e
err = e.json_body[:error] err = e.json_body[:error]
@ -52,13 +83,24 @@ module DiscourseDonations
render(json: output) && (return) render(json: output) && (return)
end end
if charge['paid'] == true if (result[:charge] && result[:charge]['paid'] == true) ||
output['messages'] << I18n.l(Time.now(), format: :long) + ': ' + I18n.t('donations.payment.success') (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: :group, name: group_name } if group_name
output['rewards'] << { type: :badge, name: badge_name } if badge_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']) args = user_params.to_h.slice(:email, :username, :password, :name).merge(rewards: output['rewards'])
Jobs.enqueue(:donation_user, args) Jobs.enqueue(:donation_user, args)
end end
@ -67,6 +109,20 @@ module DiscourseDonations
render json: output render json: output
end 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 private
def create_account def create_account
@ -97,19 +153,31 @@ module DiscourseDonations
end end
def user_params 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 end
def email def set_user
user_params[:email] || user.try(:email) user = current_user
end
def user if user_params[:user_id].present?
if user_params[:user_id] if record = User.find_by(user_params[:user_id])
User.find(user_params[:user_id]) user = record
else end
current_user
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 end
end end

View File

@ -1,11 +0,0 @@
module DiscourseDonations
class PaymentsController < ApplicationController
def index
render json: {}
end
def show
render json: {}
end
end
end

View File

@ -1,5 +0,0 @@
module DiscourseDonations
module ApplicationHelper
end
end

View File

@ -9,54 +9,242 @@ module DiscourseDonations
end end
def checkoutCharge(user = nil, email, token, amount) 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( charge = ::Stripe::Charge.create(
customer: customer.id, customer: customer.id,
amount: amount, amount: amount,
description: @description, description: @description,
currency: @currency currency: @currency
) )
charge charge
end end
def charge(user = nil, email, token, amount) def charge(user = nil, opts)
customer = customer(user, email, token) customer = customer(user,
email: opts[:email],
source: opts[:token],
create: true
)
return if !customer
@charge = ::Stripe::Charge.create( @charge = ::Stripe::Charge.create(
customer: customer.id, customer: customer.id,
amount: amount, amount: opts[:amount],
description: description, description: @description,
currency: currency currency: @currency,
receipt_email: customer.email
) )
@charge @charge
end end
def subscribe(user = nil, email, opts) def subscribe(user = nil, opts)
customer = customer(user, email, opts[:stripeToken]) customer = customer(user,
@subscription = ::Stripe::Subscription.create( email: opts[:email],
customer: customer.id, source: opts[:token],
plan: opts[:plan] 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 end
def customer(user, email, source) def list(user, opts = {})
if user && user.stripe_customer_id customer = customer(user, opts)
::Stripe::Customer.retrieve(user.stripe_customer_id)
else return if !customer
customer = ::Stripe::Customer.create(
email: email, result = { customer: customer }
source: source
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 if user
user.custom_fields['stripe_customer_id'] = customer.id user.custom_fields['stripe_customer_id'] = customer.id
user.save_custom_fields(true) user.save_custom_fields(true)
end end
customer
end end
customer
end end
def successful? def successful?
@charge[:paid] @charge[:paid]
end 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
end end

2
app/services/services.rb Normal file
View File

@ -0,0 +1,2 @@
load File.expand_path('../discourse_donations/rewards.rb', __FILE__)
load File.expand_path('../discourse_donations/stripe.rb', __FILE__)

View File

@ -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')
})

View File

@ -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()
}
});
}
}
})

View File

@ -1,21 +1,47 @@
import { ajax } from 'discourse/lib/ajax'; 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 { default as computed } from 'ember-addons/ember-computed-decorators';
import { emailValid } from "discourse/lib/utilities";
export default Ember.Component.extend({ export default Ember.Component.extend({
result: [], result: [],
amount: 1,
stripe: null, stripe: null,
transactionInProgress: null, transactionInProgress: null,
settings: null, settings: null,
showTransactionFeeDescription: false, showTransactionFeeDescription: false,
includeTransactionFee: true,
init() { init() {
this._super(); this._super();
this.set('anon', (!Discourse.User.current())); const user = this.get('currentUser');
this.set('settings', getRegister(this).lookup('site-settings:main')); const settings = Discourse.SiteSettings;
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)); 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 @computed
@ -36,9 +62,23 @@ export default Ember.Component.extend({
@computed('stripe') @computed('stripe')
card(stripe) { card(stripe) {
let elements = stripe.elements(); let elements = stripe.elements();
return elements.create('card', { let card = elements.create('card', {
hidePostalCode: !this.get('settings').discourse_donations_zip_code 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') @computed('amount')
@ -55,9 +95,48 @@ export default Ember.Component.extend({
return amount; 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() { didInsertElement() {
this._super(); this._super();
this.get('card').mount('#card-element'); 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() { setSuccess() {
@ -79,27 +158,57 @@ export default Ember.Component.extend({
submitStripeCard() { submitStripeCard() {
let self = this; let self = this;
self.set('transactionInProgress', true); this.set('transactionInProgress', true);
this.get('stripe').createToken(this.get('card')).then(data => { this.get('stripe').createToken(this.get('card')).then(data => {
self.set('result', []); self.set('result', []);
if (data.error) { if (data.error) {
self.set('result', data.error.message); this.setProperties({
stripeError: data.error.message,
stripeReady: false
});
self.endTranscation(); self.endTranscation();
} else { } else {
const transactionFeeEnabled = Discourse.SiteSettings.discourse_donations_enable_transaction_fee; const settings = Discourse.SiteSettings;
const amount = transactionFeeEnabled ? this.get('totalAmount') : this.get('amount');
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 = { let params = {
stripeToken: data.token.id, stripeToken: data.token.id,
amount: amount * 100, type: self.get('type'),
amount,
email: self.get('email'), email: self.get('email'),
username: self.get('username'), username: self.get('username'),
create_account: self.get('create_accounts') create_account: self.get('create_accounts')
}; };
if(!self.get('paymentSuccess')) { if(!self.get('paymentSuccess')) {
ajax('/charges', { data: params, method: 'post' }).then(d => { ajax('/donate/charges', {
self.concatMessages(d.messages); 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(); self.endTranscation();
}); });
} }

View File

@ -0,0 +1,12 @@
export default Ember.Controller.extend({
actions: {
confirm() {
this.get('model.confirm')();
this.send('closeModal');
},
cancel() {
this.send('closeModal');
}
}
})

View File

@ -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');
}
}
})

View File

@ -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 }

View File

@ -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);
})
}
});

View File

@ -0,0 +1,28 @@
{{#if hasSubscriptions}}
<div class="subscription-list">
<div class="underline">{{i18n 'discourse_donations.donations.subscriptions'}}</div>
<ul>
{{#each subscriptions as |s|}}
<li>{{donation-row subscription=s.subscription customer=customer new=s.new}}</li>
{{#if s.invoices}}
<ul>
{{#each s.invoices as |invoice|}}
<li>{{donation-row invoice=invoice customer=customer new=s.new}}</li>
{{/each}}
</ul>
{{/if}}
{{/each}}
</ul>
</div>
{{/if}}
{{#if hasCharges}}
<div class="charge-list">
<div class='underline'>{{i18n 'discourse_donations.donations.charges'}}</div>
<ul>
{{#each charges as |charge|}}
<li>{{donation-row charge=charge customer=customer new=charge.new}}</li>
{{/each}}
</ul>
</div>
{{/if}}

View File

@ -0,0 +1,39 @@
{{#if includePrefix}}
<span>{{i18n 'discourse_donations.invoice_prefix'}}</span>
{{/if}}
<span>{{currency}}</span>
<span>{{amount}}</span>
<span>{{period}}</span>
{{#if invoice}}
<a href='{{data.invoiceLink}}' target='_blank'>({{i18n 'discourse_donations.invoice'}})</a>
{{/if}}
{{#if currentUser}}
{{#if subscription}}
{{#if updating}}
{{loading-spinner size='small'}}
{{else}}
{{#unless canceled}}
<a {{action 'cancelSubscription'}}>
{{i18n 'cancel'}}
</a>
{{/unless}}
{{/if}}
{{/if}}
{{/if}}
{{#if receiptSent}}
<span></span>
<span>{{i18n 'discourse_donations.receipt' email=customer.email}}</span>
{{/if}}
{{#if new}}
<span class="new-flag">
{{d-icon 'circle'}}
<span>{{i18n 'new_item'}}</span>
</span>
{{/if}}

View File

@ -1,9 +1,17 @@
<form id="payment-form" class="form-horizontal"> <form id="payment-form" class="form-horizontal">
<div class="control-group">
<label class="control-label">
{{i18n 'discourse_donations.type'}}
</label>
<div class="controls controls-dropdown">
{{combo-box content=donationTypes value=type}}
</div>
</div>
<div class="control-group"> <div class="control-group">
<label class="control-label" for="card-element"> <label class="control-label" for="card-element">
{{i18n 'discourse_donations.amount'}} {{i18n 'discourse_donations.amount'}}
{{settings.discourse_donations_currency}} {{siteSettings.discourse_donations_currency}}
</label> </label>
<div class="controls controls-dropdown"> <div class="controls controls-dropdown">
{{combo-box valueAttribute="value" content=donateAmounts value=amount}} {{combo-box valueAttribute="value" content=donateAmounts value=amount}}
@ -14,7 +22,7 @@
<div class="control-group"> <div class="control-group">
<div class="controls"> <div class="controls">
{{input type="checkbox" checked=includeTransactionFee}} {{input type="checkbox" checked=includeTransactionFee}}
{{i18n 'discourse_donations.transaction_fee.label' transactionFee=transactionFee currency=settings.discourse_donations_currency}} <span>{{i18n 'discourse_donations.transaction_fee.label' transactionFee=transactionFee currency=siteSettings.discourse_donations_currency}}</span>
<div class='transaction-fee-description' {{action 'toggleTransactionFeeDescription'}}> <div class='transaction-fee-description' {{action 'toggleTransactionFeeDescription'}}>
{{d-icon 'info-circle'}} {{d-icon 'info-circle'}}
{{#if showTransactionFeeDescription}} {{#if showTransactionFeeDescription}}
@ -25,27 +33,39 @@
</div> </div>
</div> </div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label class='control-label'> <label class='control-label'>
{{i18n 'discourse_donations.transaction_fee.total'}} {{i18n 'discourse_donations.transaction_fee.total'}}
</label> </label>
<div class="controls"> <div class="controls">
{{settings.discourse_donations_currency}} {{siteSettings.discourse_donations_currency}}
{{totalAmount}} {{totalAmount}}
{{period}}
</div> </div>
</div> </div>
{{/if}} {{/if}}
<div class="control-group" style="width: 550px;"> <div class="control-group" style="width: 550px;">
<label class="control-label" for="card-element">{{i18n 'discourse_donations.card'}}</label> <label class="control-label" for="card-element">{{i18n 'discourse_donations.card'}}</label>
<div id="card-element" class="controls"></div> <div class="controls">
<div id="card-element"></div>
{{#if stripeError}}
<div class="stripe-error">{{stripeError}}</div>
{{/if}}
</div>
</div> </div>
{{#if anon}} {{#unless currentUser}}
<div class="control-group"> <div class="control-group">
<label class="control-label" for="card-element">{{i18n 'user.email.title'}}</label> <label class="control-label" for="card-element">{{i18n 'user.email.title'}}</label>
<div class="controls"> <div class="controls">
{{text-field value=email}} {{text-field value=email}}
{{#if showEmailError}}
<div class="error">{{i18n 'user.email.invalid'}}</div>
{{else}}
<div class="instructions">{{i18n 'discourse_donations.email_instructions'}}</div>
{{/if}}
</div> </div>
</div> </div>
@ -71,11 +91,11 @@
</div> </div>
</div> </div>
{{/if}} {{/if}}
{{/if}} {{/unless}}
<div class="control-group save-button"> <div class="control-group save-button">
<div class="controls"> <div class="controls">
{{#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}} {{#if create_accounts}}
{{i18n 'discourse_donations.submit_with_create_account'}} {{i18n 'discourse_donations.submit_with_create_account'}}
{{else}} {{else}}

View File

@ -1,5 +1,5 @@
{{#if siteSettings.discourse_donations_enabled}} {{#if siteSettings.discourse_donations_enabled}}
<a href="/donate"> <a href="/donate">
{{i18n 'discourse_donations.nav_item'}} {{i18n 'discourse_donations.nav_item'}}
</a> </a>
{{/if}} {{/if}}

View File

@ -1,7 +1,36 @@
<h1>{{i18n 'discourse_donations.title' site_name=siteSettings.title}}</h1> <h3>{{i18n 'discourse_donations.title' site_name=siteSettings.title}}</h3>
<div class="donations-page-description"> <div class="donations-page-description">
{{cook-text siteSettings.discourse_donations_page_description}} {{cook-text siteSettings.discourse_donations_page_description}}
</div> </div>
<div class="donations-page-payment"> <div class="donations-page-payment">
{{stripe-card}} {{stripe-card charges=charges subscriptions=subscriptions}}
</div>
<div class="donations-page-donations">
<h3>{{i18n 'discourse_donations.donations.title'}}</h3>
{{#if loadingDonations}}
<span>{{i18n 'discourse_donations.donations.loading'}}</span>
{{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}}
</div> </div>

View File

@ -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}}
<div class="modal-footer">
{{d-button action='confirm' label='yes_value' class='btn-primary'}}
{{d-button action='cancel' label='no_value'}}
</div>

View File

@ -13,7 +13,26 @@ div.stripe-errors {
} }
.donations-page-payment { .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 { .transaction-fee-description {
@ -35,3 +54,57 @@ div.stripe-errors {
width: 400px; width: 400px;
z-index: 1; 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;
}
}
}

View File

@ -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_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 <a href='https://stripe.com/pricing'>Stripe's pricing for your region</a> and <a href='https://support.stripe.com/questions/can-i-charge-my-stripe-fees-to-my-customers'>Stripe's explaination of passing fees onto customers</a>." discourse_donations_transaction_fee_fixed: "Fixed part of Stripe transaction fee (changes per region). See <a href='https://stripe.com/pricing'>Stripe's pricing for your region</a> and <a href='https://support.stripe.com/questions/can-i-charge-my-stripe-fees-to-my-customers'>Stripe's explaination of passing fees onto customers</a>."
discourse_donations_transaction_fee_percent: "Percent part of Stripe transaction fee (changes per region). See <a href='https://stripe.com/pricing'>Stripe's pricing for your region</a> and <a href='https://support.stripe.com/questions/can-i-charge-my-stripe-fees-to-my-customers'>Stripe's explaination of passing fees onto customers</a>." discourse_donations_transaction_fee_percent: "Percent part of Stripe transaction fee (changes per region). See <a href='https://stripe.com/pricing'>Stripe's pricing for your region</a> and <a href='https://support.stripe.com/questions/can-i-charge-my-stripe-fees-to-my-customers'>Stripe's explaination of passing fees onto customers</a>."
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_custom_amount: "Allow custom donation amount"
discourse_donations_types: "Donation types. First listed will be the default."
errors: errors:
discourse_donations_amount_must_be_number: "Amounts must be numbers" discourse_donations_amount_must_be_number: "Amounts must be numbers"
js: js:
discourse_donations: discourse_donations:
title: Donate
nav_item: Donate nav_item: Donate
title: "Make a Donation"
amount: Amount amount: Amount
card: Credit or debit card card: Card
submit: Make Payment submit: Donate
submit_with_create_account: Make Payment and Create Account 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: transaction_fee:
label: "Include transaction fee of {{currency}} {{transactionFee}}" 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" total: "Total"
messages: messages:
success: Thank you for your donation! 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}}."

View File

@ -1,4 +1,11 @@
en: en:
donations: donations:
recurring: "%{site_title} Recurring Donation"
payment: 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."

View File

@ -1,6 +1,8 @@
DiscourseDonations::Engine.routes.draw do 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] resources :checkout, only: [:create]
get 'users/:username/payments' => 'payments#show'
get 'donate' => 'payments#show'
end end

View File

@ -50,3 +50,12 @@ plugins:
default: '1|2|5|10|20|50' default: '1|2|5|10|20|50'
regex: "^[0-9\\|]+$" regex: "^[0-9\\|]+$"
regex_error: "site_settings.errors.discourse_donations_amount_must_be_number" 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

View File

@ -1,4 +1,3 @@
module ::DiscourseDonations module ::DiscourseDonations
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
engine_name 'discourse-donations' engine_name 'discourse-donations'

View File

@ -2,12 +2,10 @@
# about: Integrates Stripe into Discourse to allow forum visitors to make donations # about: Integrates Stripe into Discourse to allow forum visitors to make donations
# version: 1.11.1 # version: 1.11.1
# url: https://github.com/chrisbeach/discourse-donations # url: https://github.com/chrisbeach/discourse-donations
# authors: Rimian Perkins, Chris Beach # authors: Rimian Perkins, Chris Beach, Angus McLeod
gem 'stripe', '2.8.0' gem 'stripe', '2.8.0'
load File.expand_path('../lib/discourse_donations/engine.rb', __FILE__)
register_asset "stylesheets/discourse-donations.scss" register_asset "stylesheets/discourse-donations.scss"
enabled_site_setting :discourse_donations_enabled enabled_site_setting :discourse_donations_enabled
@ -17,19 +15,23 @@ register_html_builder('server:before-head-close') do
end end
after_initialize do 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/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 class ::User
def stripe_customer_id def stripe_customer_id
if custom_fields['stripe_customer_id'] if custom_fields['stripe_customer_id']
custom_fields['stripe_customer_id'] custom_fields['stripe_customer_id'].to_s
else else
nil nil
end end
end end
end end
end end
Discourse::Application.routes.prepend do
mount ::DiscourseDonations::Engine, at: '/'
end