This commit is contained in:
Angus McLeod 2018-06-28 09:32:58 +10:00
parent fd0b53c67c
commit 1b1eb215e4
18 changed files with 478 additions and 140 deletions

View File

@ -1,14 +1,20 @@
module DiscourseDonations
class ChargesController < ::ApplicationController
skip_before_action :verify_authenticity_token, only: [:create]
before_action :ensure_logged_in, only: [:cancel_subscription]
before_action :set_user, only: [:index, :create]
before_action :set_email, only: [:create]
before_action :set_email, only: [:index, :create, :cancel_subscription]
def index
if @user && @user.stripe_customer_id
result = DiscourseDonations::Stripe.new(secret_key, stripe_options).list(@user)
else
result = {}
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)
@ -36,7 +42,7 @@ 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
@ -48,17 +54,27 @@ module DiscourseDonations
}
if user_params[:type] === 'once'
result[:payment] = payment.charge(@user, opts)
result[:charge] = stripe.charge(@user, opts)
else
opts[:type] = user_params[:type]
result[:subscription] = payment.subscribe(@user, opts)
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
rescue ::Stripe::CardError => e
err = e.json_body[:error]
puts "HERE IS THE ERROR: #{e.inspect}"
output['messages'] << "There was an error (#{err[:type]})."
output['messages'] << "Error code: #{err[:code]}" if err[:code]
output['messages'] << "Decline code: #{err[:decline_code]}" if err[:decline_code]
@ -68,18 +84,16 @@ module DiscourseDonations
end
if (result[:charge] && result[:charge]['paid'] == true) ||
(result[:subscription] && result[:subscription]['status'] === 'active')
(result[:subscription] && result[:subscription][:subscription] &&
result[:subscription][:subscription]['status'] === 'active')
output['messages'] << I18n.t('donations.payment.success')
if result[:charge]
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
if result[:subscription]
output['messages'] << " #{I18n.t('donations.payment.invoice_sent', email: @email)}"
end
output['charge'] = result[:charge] if result[:charge]
output['subscription'] = result[:subscription] if result[:subscription]
@ -95,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
@ -132,16 +160,20 @@ module DiscourseDonations
user = current_user
if user_params[:user_id].present?
user = User.find(user_params[:user_id])
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]
else
elsif @user
email = @user.try(:email)
end

View File

@ -9,7 +9,13 @@ 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,
@ -22,7 +28,13 @@ module DiscourseDonations
end
def charge(user = nil, opts)
customer = customer(user, opts[:email], opts[:token])
customer = customer(user,
email: opts[:email],
source: opts[:token],
create: true
)
return if !customer
@charge = ::Stripe::Charge.create(
customer: customer.id,
@ -36,28 +48,40 @@ module DiscourseDonations
end
def subscribe(user = nil, opts)
customer = customer(user, opts[:email], opts[:token])
customer = customer(user,
email: opts[:email],
source: opts[:token],
create: true
)
return if !customer
type = opts[:type]
amount = opts[:amount]
plans = ::Stripe::Plan.list
type = opts[:type]
plan_id = create_plan_id(type)
plan_id = create_plan_id(type, amount)
unless plans.data && plans.data.any? { |p| p['id'] === plan_id }
result = create_plan(type, opts[:amount])
result = create_plan(type, amount)
plan_id = result['id']
end
@subscription = ::Stripe::Subscription.create(
::Stripe::Subscription.create(
customer: customer.id,
items: [{ plan: plan_id }]
items: [{
plan: plan_id
}]
)
@subscription
end
def list(user)
customer = customer(user)
result = {}
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'] : []
@ -66,7 +90,8 @@ module DiscourseDonations
raw_charges = raw_charges.is_a?(Object) ? raw_charges['data'] : []
if raw_invoices.any?
raw_subscriptions = ::Stripe::Subscription.list(customer: customer.id)['data']
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 = []
@ -97,22 +122,82 @@ module DiscourseDonations
result
end
def customer(user, email = nil, source = nil)
if user && user.stripe_customer_id
::Stripe::Customer.retrieve(user.stripe_customer_id)
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: email,
source: source
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?
@ -120,7 +205,7 @@ module DiscourseDonations
end
def create_plan(type, amount)
id = create_plan_id(type)
id = create_plan_id(type, amount)
nickname = id.gsub(/_/, ' ').titleize
products = ::Stripe::Product.list(type: 'service')
@ -151,15 +236,15 @@ module DiscourseDonations
end
def product_id
@product_id ||= "#{SiteSetting.title}_recurring_donation"
@product_id ||= "#{SiteSetting.title}_recurring_donation".freeze
end
def product_name
@product_name ||= I18n.t('discourse_donations.recurring', site_title: SiteSetting.title)
@product_name ||= I18n.t('donations.recurring', site_title: SiteSetting.title)
end
def create_plan_id(type)
"discourse_donation_recurring_#{type}"
def create_plan_id(type, amount)
"discourse_donation_recurring_#{type}_#{amount}".freeze
end
end
end

View File

@ -1,3 +1,6 @@
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'),

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

@ -175,7 +175,7 @@ export default Ember.Component.extend({
const transactionFeeEnabled = settings.discourse_donations_enable_transaction_fee;
let amount = transactionFeeEnabled ? this.get('totalAmount') : this.get('amount');
if (zeroDecimalCurrencies.indexOf(setting.discourse_donations_currency) === -1) {
if (zeroDecimalCurrencies.indexOf(settings.discourse_donations_currency) === -1) {
amount = amount * 100;
}
@ -189,22 +189,26 @@ export default Ember.Component.extend({
};
if(!self.get('paymentSuccess')) {
ajax('/donate/charges', { data: params, method: 'post' }).then(d => {
let donation = d.donation;
if (donation) {
if (donation.object === 'subscription') {
let subscriptions = this.get('subscriptions') || [];
subscriptions.push(donation);
this.set('subscriptions', subscriptions);
} else if (donation.object === 'charge') {
let charges = this.get('charges') || [];
charges.push(donation);
this.set('charges', charges);
}
ajax('/donate/charges', {
data: params,
method: 'post'
}).then(result => {
if (result.subscription) {
let subscription = $.extend({}, result.subscription, {
new: true
});
this.get('subscriptions').unshiftObject(subscription);
}
self.concatMessages(d.messages);
if (result.charge) {
let charge = $.extend({}, result.charge, {
new: true
});
this.get('charges').unshiftObject(charge);
}
self.concatMessages(result.messages);
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

@ -1,11 +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,
@computed('charges', 'subscriptions')
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

@ -1,56 +0,0 @@
import { registerHelper } from "discourse-common/lib/helpers";
import { formatAnchor, formatAmount } from '../lib/donation-utilities';
registerHelper("donation-subscription", function([subscription]) {
let currency = subscription.plan.currency.toUpperCase();
let html = currency;
html += ` ${formatAmount(subscription.plan.amount, currency)} `;
html += I18n.t(`discourse_donations.period.${subscription.plan.interval}`, {
anchor: formatAnchor(subscription.plan.interval, moment.unix(subscription.billing_cycle_anchor))
});
return new Handlebars.SafeString(html);
});
registerHelper("donation-invoice", function([invoice]) {
let details = invoice.lines.data[0];
let html = I18n.t('discourse_donations.invoice_prefix');
let currency = details.currency.toUpperCase();
html += ` ${currency}`;
html += ` ${formatAmount(details.amount, currency)} `;
html += I18n.t(`discourse_donations.period.once`, {
anchor: formatAnchor('once', moment.unix(invoice.date))
});
if (invoice.invoice_pdf) {
html += ` (<a href='${invoice.invoice_pdf}' target='_blank'>${I18n.t('discourse_donations.invoice')}</a>)`;
}
return new Handlebars.SafeString(html);
});
registerHelper("donation-charge", function([charge]) {
let html = I18n.t('discourse_donations.invoice_prefix');
let currency = charge.currency.toUpperCase();
html += ` ${currency}`;
html += ` ${formatAmount(charge.amount, currency)} `;
html += I18n.t(`discourse_donations.period.once`, {
anchor: formatAnchor('once', moment.unix(charge.created))
});
if (charge.receipt_email) {
html += `. ${I18n.t('discourse_donations.receipt', {
email: charge.receipt_email
})}`;
}
return new Handlebars.SafeString(html);
});

View File

@ -4,15 +4,24 @@ 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 && (result.charges || result.subscriptions)) {
controller.setProperties({
charges: result.charges,
subscriptions: result.subscriptions
});
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

@ -3,12 +3,11 @@
<div class="underline">{{i18n 'discourse_donations.donations.subscriptions'}}</div>
<ul>
{{#each subscriptions as |s|}}
<li>{{donation-subscription s.subscription}}</li>
<li>{{donation-row subscription=s.subscription customer=customer new=s.new}}</li>
{{#if s.invoices}}
<ul>
{{#each s.invoices as |invoice|}}
<li>{{donation-invoice invoice}}</li>
<li>{{donation-row invoice=invoice customer=customer new=s.new}}</li>
{{/each}}
</ul>
{{/if}}
@ -22,7 +21,7 @@
<div class='underline'>{{i18n 'discourse_donations.donations.charges'}}</div>
<ul>
{{#each charges as |charge|}}
<li>{{donation-charge charge}}</li>
<li>{{donation-row charge=charge customer=customer new=charge.new}}</li>
{{/each}}
</ul>
</div>

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

@ -33,6 +33,7 @@
</div>
</div>
</div>
<div class="control-group">
<label class='control-label'>
{{i18n 'discourse_donations.transaction_fee.total'}}

View File

@ -8,17 +8,29 @@
{{stripe-card charges=charges subscriptions=subscriptions}}
</div>
{{#if currentUser}}
<div class="donations-page-donations">
<h3>{{i18n 'discourse_donations.donations.title'}}</h3>
{{#if loadingDonations}}
{{loading-spinner size='small'}}
{{else}}
<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}}
{{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}}
</div>
{{/if}}
{{/if}}
</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

@ -61,11 +61,15 @@ div.stripe-errors {
.donation-list {
.subscription-list, .charge-list {
margin-bottom: 10px;
display: inline-block;
> ul {
margin: 10px 0;
list-style: none;
.spinner {
height: 5px;
width: 5px;
}
}
}
@ -75,3 +79,32 @@ div.stripe-errors {
}
}
}
.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

@ -27,11 +27,17 @@ en:
title: "Make a Donation"
amount: Amount
card: Card
submit: Make Payment
submit: Donate
submit_with_create_account: Make Payment and Create Account
invoice: "Invoice"
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}}"
@ -52,6 +58,9 @@ en:
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,7 +1,11 @@
en:
donations:
recurring: "%{site_title} recurring donation"
recurring: "%{site_title} Recurring Donation"
payment:
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,5 +1,8 @@
DiscourseDonations::Engine.routes.draw do
get '/' => 'charges#index'
resources :charges, only: [:index, :create]
put '/charges/cancel-subscription' => 'charges#cancel_subscription'
resources :checkout, only: [:create]
end