diff --git a/app/components/donation/card-item.js b/app/components/donation/card-item.js
new file mode 100644
index 000000000..c93c40007
--- /dev/null
+++ b/app/components/donation/card-item.js
@@ -0,0 +1,21 @@
+import Ember from 'ember';
+
+const {
+ Component,
+ computed
+} = Ember;
+
+export default Component.extend({
+ classNames: ['card-item'],
+ classNameBindings: ['isSelected:selected'],
+
+ isSelected: computed('card', 'selectedCard', function() {
+ return this.get('card.id') === this.get('selectedCard.id');
+ }),
+
+ selectedCard: null,
+
+ click() {
+ this.get('select')();
+ }
+});
diff --git a/app/components/donation/card-list.js b/app/components/donation/card-list.js
new file mode 100644
index 000000000..febdbe4f7
--- /dev/null
+++ b/app/components/donation/card-list.js
@@ -0,0 +1,19 @@
+import Ember from 'ember';
+
+const {
+ Component,
+ computed: { alias, empty }
+} = Ember;
+
+export default Component.extend({
+ classNames: ['card-list'],
+
+ cardNotSelected: empty('selectedCard'),
+ donationDisabled: alias('cardNotSelected'),
+
+ selectedCard: null,
+
+ selectCard(card) {
+ this.set('selectedCard', card);
+ }
+});
diff --git a/app/components/donation/credit-card.js b/app/components/donation/credit-card.js
index 8ac896308..a067c5d74 100644
--- a/app/components/donation/credit-card.js
+++ b/app/components/donation/credit-card.js
@@ -2,11 +2,89 @@ import Ember from 'ember';
const {
Component,
- computed: { not }
+ computed,
+ computed: { and, not, or },
+ inject: { service }
} = Ember;
export default Component.extend({
classNames: ['credit-card-form'],
- canDonate: true,
- cannotDonate: not('canDonate')
+ month: '',
+ months: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'],
+ year: '',
+ years: [],
+
+ /**
+ @property stripe
+ @type Ember.Service
+ */
+ stripe: service(),
+
+ cardInvalid: not('cardValid'),
+ cardValid: and('isCardNumberValid', 'isCVCValid', 'isExpiryValid'),
+
+ preventSubmit: or('isSubmitting', 'cardInvalid'),
+
+ isSubmitting: false,
+
+ date: computed('month', 'year', function() {
+ let month = this.get('month');
+ let year = this.get('year');
+ return `${month} ${year}`;
+ }),
+
+ isCardNumberValid: computed('cardNumber', function() {
+ let stripe = this.get('stripe');
+ let cardNumber = this.get('cardNumber');
+ return stripe.card.validateCardNumber(cardNumber);
+ }),
+
+ isCVCValid: computed('cvc', function() {
+ let stripe = this.get('stripe');
+ let cvc = this.get('cvc');
+ return stripe.card.validateCVC(cvc);
+ }),
+
+ isExpiryValid: computed('date', function() {
+ let stripe = this.get('stripe');
+ let date = this.get('date');
+ return stripe.card.validateExpiry(date);
+ }),
+
+ init() {
+ let date = new Date();
+ let currentMonth = `0${date.getMonth() + 1}`.slice(-2);
+ this.set('month', currentMonth);
+ let currentYear = date.getFullYear();
+ this.set('year', currentYear);
+ let years = this.generateYears(currentYear);
+ this.set('years', years);
+ this._super(...arguments);
+ },
+
+ generateYears(currentYear) {
+ let years = [];
+ let endYear = currentYear + 20;
+ while (endYear >= currentYear) {
+ years.push(currentYear++);
+ }
+ return years;
+ },
+
+ _afterSubmit() {
+ if (this.get('isDestroyed')) {
+ return;
+ }
+ this.set('isSubmitting', false);
+ },
+
+ actions: {
+ submit() {
+ this.set('isSubmitting', true);
+ let cardAttrs = this.getProperties('cvc', 'cardNumber', 'year', 'month');
+ let onSubmit = this.get('submit');
+
+ onSubmit(cardAttrs).finally(() => this._afterSubmit());
+ }
+ }
});
diff --git a/app/components/donation/donation-container.js b/app/components/donation/donation-container.js
index e6f63bfaf..fd8da6457 100644
--- a/app/components/donation/donation-container.js
+++ b/app/components/donation/donation-container.js
@@ -2,13 +2,25 @@ import Ember from 'ember';
const {
Component,
- computed: { bool }
+ computed,
+ computed: {
+ and, gt, not
+ }
} = Ember;
export default Component.extend({
+ // canAddCard: true,
classNames: ['donation-container'],
donationAmount: 0,
projectTitle: null,
- canDonate: bool('projectTitle')
+ canAddCard: computed('hasCards', 'isAddingCard', function() {
+ let { hasCards, isAddingCard } = this.getProperties('hasCards', 'isAddingCard');
+ return hasCards ? isAddingCard : true;
+ }),
+ canDonate: and('hasCards', 'isNotAddingCard'),
+ canShowCardList: and('hasCards', 'isNotAddingCard'),
+ hasCards: gt('cards.length', 0),
+ hasNoCards: not('hasCards'),
+ isNotAddingCard: not('isAddingCard')
});
diff --git a/app/components/donations/create-donation.js b/app/components/donations/create-donation.js
index 1c36fb4e9..ba21d0819 100644
--- a/app/components/donations/create-donation.js
+++ b/app/components/donations/create-donation.js
@@ -1,9 +1,14 @@
import Ember from 'ember';
-const { Component } = Ember;
+const { Component, computed, isEmpty } = Ember;
export default Component.extend({
classNames: ['create-donation'],
presetAmounts: [10, 15, 25, 50],
- amount: 15
+ amount: 10,
+
+ selectedAmount: computed('amount', 'customAmount', function() {
+ let { amount, customAmount } = this.getProperties('amount', 'customAmount');
+ return isEmpty(customAmount) ? amount : customAmount;
+ })
});
diff --git a/app/components/donations/donation-amount-button.js b/app/components/donations/donation-amount-button.js
index 4a0ab21a1..b0d7fb841 100644
--- a/app/components/donations/donation-amount-button.js
+++ b/app/components/donations/donation-amount-button.js
@@ -1,19 +1,21 @@
import Ember from 'ember';
-const { Component, computed } = Ember;
+const { Component, computed, isEmpty } = Ember;
export default Component.extend({
classNames: ['preset-amount'],
classNameBindings: ['presetAmount', 'selected:default:clear'],
tagName: 'button',
- selected: computed('presetAmount', 'selectedAmount', function() {
- let { presetAmount, selectedAmount } = this.getProperties('presetAmount', 'selectedAmount');
- return parseInt(presetAmount) === parseInt(selectedAmount);
+ selected: computed('customAmount', 'presetAmount', 'selectedAmount', function() {
+ let { customAmount, presetAmount, selectedAmount } = this.getProperties('customAmount', 'presetAmount', 'selectedAmount');
+ let amountInPresets = parseInt(presetAmount) === parseInt(selectedAmount);
+ return isEmpty(customAmount) ? amountInPresets : false;
}),
click() {
+ this.sendAction('setCustomAmount', null);
let presetAmount = this.get('presetAmount');
- this.sendAction('action', presetAmount);
+ this.sendAction('setAmount', presetAmount);
}
});
diff --git a/app/components/error-formatter.js b/app/components/error-formatter.js
index abcb8687e..c7a41fcb4 100644
--- a/app/components/error-formatter.js
+++ b/app/components/error-formatter.js
@@ -1,6 +1,6 @@
import Ember from 'ember';
-const { Component, computed } = Ember;
+const { Component, computed, Object, String } = Ember;
/**
`error-formatter' returns a formatted error message. Place within an 'if'
@@ -36,9 +36,85 @@ export default Component.extend({
@property messages
@type String
*/
- messages: computed('error.errors', function() {
- return (this.get('error.errors') || []).map((e) => {
- return `${e.title}: ${e.detail}`;
+ messages: computed('error', function() {
+ let errorResponse = Object.create(this.get('error'));
+ let handler = this._findHandler(errorResponse);
+ if (handler) {
+ return handler(errorResponse);
+ }
+ }),
+
+ /**
+ * Determines the type of error from an error response and returns
+ * the correct messsage formatter function for it.
+ *
+ * @param {Object} errorResponse The error response received from the server
+ * @return {Function} Function which takes the error response and returns a list of messages
+ */
+ _findHandler(errorResponse) {
+ if (errorResponse.get('isAdapterError')) {
+ return this._findHandlerForAdapterError(errorResponse);
+ } else if (errorResponse.get('error.type') === 'card_error') {
+ return this._stripeCardErrorMessages;
+ }
+ },
+
+ /**
+ * If the error response is determined to be an adapter error, this
+ * function further determines the type of adapter error and returns
+ * the correct message formatter for it.
+ * @param {Object} errorResponse The adapter error response received from the server
+ * @return {Function} Function which takes the response and returns a list of messages
+ */
+ _findHandlerForAdapterError(errorResponse) {
+ let payloadContainsValidationErrors = errorResponse.errors.some((error) => error.status === 422);
+
+ if (payloadContainsValidationErrors) {
+ return this._validationErrorMessages;
+ } else {
+ return this._adapterErrorMessages;
+ }
+ },
+
+ /**
+ * Formats messages for a validation error response
+ * In most cases, we do not need this, since we do not render those elements outside
+ * a form. In some cases, however, we are creating a record in the background, so
+ * we are forced to render those errors separate from a form.
+ *
+ * @param {Object} errorResponse The payload received from the server
+ * @return {Array} An array of string messages
+ */
+ _validationErrorMessages(errorResponse) {
+ return (errorResponse.get('errors')).map((e) => {
+ let attributePathElements = e.source.pointer.split('/');
+ let unformattedAttributeName = attributePathElements[attributePathElements.length - 1];
+ let attributeName = String.capitalize(unformattedAttributeName);
+ return `${attributeName} ${e.detail}`;
});
- })
+ },
+
+ /**
+ * Formats messages for an adapter error response.
+ * An adapter error response contains an array of errors with a
+ * `title` and a `detail` property, but in most cases, that array only contains
+ * one error.
+ * @param {Object} errorResponse Response received from the server
+ * @return {Array} Array of message strings
+ */
+ _adapterErrorMessages(errorResponse) {
+ return (errorResponse.get('errors')).map((e) => `${e.title}: ${e.detail}`);
+ },
+
+ /**
+ * Formats messages for a stripe card error response.
+ *
+ * The response contains an `error` object, for which the relevant key is
+ * the `message` property.
+ * @param {Object} errorResponse Error response received from the stripe service
+ * @return {Array} An array of string messages, containing single string
+ */
+ _stripeCardErrorMessages(errorResponse) {
+ return [errorResponse.get('error.message')];
+ }
});
diff --git a/app/controllers/project/donate.js b/app/controllers/project/donate.js
index 125d3666c..a28356198 100644
--- a/app/controllers/project/donate.js
+++ b/app/controllers/project/donate.js
@@ -1,8 +1,107 @@
import Ember from 'ember';
-const { Controller } = Ember;
+const {
+ Controller,
+ computed: { alias },
+ inject: { service }
+} = Ember;
export default Controller.extend({
+ amount: null,
+ isAddingCard: false,
queryParams: ['amount'],
- amount: null
+
+ currentUser: service(),
+ store: service(),
+ stripe: service(),
+
+ project: alias('model'),
+ user: alias('currentUser.user'),
+
+ actions: {
+ addCard(cardParams) {
+ return this._createCreditCardToken(cardParams)
+ .then((tokenData) => this._createCustomerAndCard(tokenData))
+ .then((stripeCard) => this._addCard(stripeCard))
+ .catch((reason) => this._handleError(reason))
+ .finally(() => this._updateAddingCardState());
+ },
+
+ donate(amount, stripeCard) {
+ // I don't think we need a token anymore once the card was created
+ // Should be enough to just pass in the selected stripe card. Stripe can use it
+ // via id, I think
+ return this._createSubscription(amount, stripeCard)
+ .then(() => this._transitionToThankYou())
+ .catch((reason) => this._handleError(reason));
+ }
+ },
+
+ _createCreditCardToken(cardParams) {
+ let stripeCard = this._tokenParams(cardParams);
+ let stripe = this.get('stripe');
+
+ return stripe.card.createToken(stripeCard);
+ },
+
+ _createCustomerAndCard({ id, card }) {
+ // TODO: Conditional create here
+ return this._createStripeCustomer(id)
+ .then((/* stripeCustomer */) => this._createStripeCard(card));
+ },
+
+ _createStripeCustomer(/* token */) {
+ let { user, store } = this.getProperties('user', 'store');
+ let email = user.get('email');
+
+ return store.createRecord('stripe-customer', { email, user })
+ .save();
+ },
+
+ _createStripeCard(cardData) {
+ let user = this.get('user');
+ let params = this._cardParams(cardData);
+
+ return user.get('stripeCards')
+ .createRecord(params)
+ .save();
+ },
+
+ _addCard(stripeCard) {
+ return this.get('user.stripeCards').pushObject(stripeCard);
+ },
+
+ _createSubscription(amount, stripeCard) {
+ let { project, store, user } = this.getProperties('project', 'store', 'user');
+ let subscription = store.createRecord('stripe-subscription', { amount, project, user, stripeCard });
+
+ return subscription.save();
+ },
+
+ _transitionToThankYou() {
+ let project = this.get('project');
+ return this.transitionToRoute('project.thankyou', project);
+ },
+
+ _handleError(error) {
+ this.set('error', error);
+ },
+
+ _tokenParams(cardParams) {
+ return {
+ number: cardParams.cardNumber,
+ cvc: cardParams.cvc,
+ exp_month: cardParams.month,
+ exp_year: cardParams.year
+ };
+ },
+
+ _updateAddingCardState() {
+ this.set('isAddingCard', false);
+ },
+
+ _cardParams(params) {
+ let { last4, name, brand, country, exp_month, exp_year } = params;
+ return { brand, country, expMonth: exp_month, expYear: exp_year, last4, name };
+ }
});
diff --git a/app/models/stripe-card.js b/app/models/stripe-card.js
new file mode 100644
index 000000000..5796b5691
--- /dev/null
+++ b/app/models/stripe-card.js
@@ -0,0 +1,14 @@
+import Model from 'ember-data/model';
+import attr from 'ember-data/attr';
+import { belongsTo } from 'ember-data/relationships';
+
+export default Model.extend({
+ brand: attr(),
+ country: attr(),
+ expMonth: attr(),
+ expYear: attr(),
+ last4: attr(),
+ name: attr(),
+
+ user: belongsTo('user', { async: true })
+});
diff --git a/app/models/stripe-customer.js b/app/models/stripe-customer.js
new file mode 100644
index 000000000..5d5d9205b
--- /dev/null
+++ b/app/models/stripe-customer.js
@@ -0,0 +1,9 @@
+import Model from 'ember-data/model';
+import attr from 'ember-data/attr';
+import { belongsTo } from 'ember-data/relationships';
+
+export default Model.extend({
+ email: attr(),
+
+ user: belongsTo('user', { async: true })
+});
diff --git a/app/models/user.js b/app/models/user.js
index 7cb5e9ef1..c237c7f96 100644
--- a/app/models/user.js
+++ b/app/models/user.js
@@ -1,6 +1,6 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
-import { hasMany } from 'ember-data/relationships';
+import { belongsTo, hasMany } from 'ember-data/relationships';
import Ember from 'ember';
const { computed } = Ember;
@@ -28,6 +28,8 @@ export default Model.extend({
userCategories: hasMany('user-category', { async: true }),
userRoles: hasMany('user-role', { async: true }),
userSkills: hasMany('user-skill', { async: true }),
+ stripeCards: hasMany('stripe-card', { async: true }),
+ stripeCustomer: belongsTo('stripe-customer', { async: true }),
atUsername: computed('username', function() {
return `@${this.get('username')}`;
diff --git a/app/router.js b/app/router.js
index 120dcc7ae..d69dc6864 100644
--- a/app/router.js
+++ b/app/router.js
@@ -57,6 +57,7 @@ AppRouter.map(function() {
this.route('task', { path: '/:number' });
});
this.route('donate');
+ this.route('thankyou');
});
this.route('projects', {
diff --git a/app/routes/project/donate.js b/app/routes/project/donate.js
new file mode 100644
index 000000000..16240adb3
--- /dev/null
+++ b/app/routes/project/donate.js
@@ -0,0 +1,8 @@
+import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin';
+import Ember from 'ember';
+
+const {
+ Route
+} = Ember;
+
+export default Route.extend(AuthenticatedRouteMixin, {});
diff --git a/app/routes/project/thankyou.js b/app/routes/project/thankyou.js
new file mode 100644
index 000000000..e78f5a40a
--- /dev/null
+++ b/app/routes/project/thankyou.js
@@ -0,0 +1,7 @@
+import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin';
+import Ember from 'ember';
+
+const { Route } = Ember;
+
+export default Route.extend(AuthenticatedRouteMixin, {
+});
diff --git a/app/styles/_buttons.scss b/app/styles/_buttons.scss
index 171ee30bf..003759899 100644
--- a/app/styles/_buttons.scss
+++ b/app/styles/_buttons.scss
@@ -182,11 +182,10 @@
#{$all-buttons-hover}, .button:hover {
cursor: pointer;
- $darker-color: darken($primary-color, 5%);
- background: $darker-color;
- background-image: linear-gradient($default-color, $darker-color);
- border-color: $darker-color;
- @include transition(background 0.2s ease-in);
+
+ &.default, &.clear, &.success, &.danger {
+ @include transition(background 0.2s ease-in);
+ }
&.default {
$darker-color: darken($default-color, 5%);
@@ -229,7 +228,7 @@
}
&:disabled {
- cursor: default;
+ cursor: not-allowed;
}
}
@@ -305,10 +304,6 @@ p {
background: white;
}
}
-
- &:disabled {
- cursor: not-allowed;
- }
}
select {
diff --git a/app/styles/_forms.scss b/app/styles/_forms.scss
index ccf5fdd75..930631b18 100644
--- a/app/styles/_forms.scss
+++ b/app/styles/_forms.scss
@@ -46,10 +46,14 @@ $outer-border-radius: 4px;
.row {
display: flex;
justify-content: space-between;
+ }
- label {
- width: 49%;
- position: relative;
+ label {
+ span {
+ display: block;
+ font-size: $body-font-size-small;
+ font-weight: 700;
+ margin-bottom: 3px;
}
}
@@ -186,14 +190,19 @@ form {
}
}
+select {
+ border: 1px solid $border-default;
+ font-family: $body-font-family;
+ font-size: $body-font-size-normal;
+ height: 41px;
+ padding: 0 16px;
+ cursor: pointer;
+}
+
.task-type {
select {
border: none;
- font-family: $body-font-family;
- font-size: $body-font-size-normal;
height: 34px;
- padding: 0 16px;
- cursor: pointer;
width: 100px;
&:focus {
diff --git a/app/styles/app.scss b/app/styles/app.scss
index b9874a40a..8c75a02e7 100644
--- a/app/styles/app.scss
+++ b/app/styles/app.scss
@@ -81,6 +81,7 @@
@import "templates/about";
@import "templates/project/index";
+@import "templates/project/thankyou";
@import "templates/project/settings/contributors";
@import "templates/start/expertise";
@import "templates/start/hello";
diff --git a/app/styles/components/donation-payment.scss b/app/styles/components/donation-payment.scss
index 5939b11c3..8c4ca0492 100644
--- a/app/styles/components/donation-payment.scss
+++ b/app/styles/components/donation-payment.scss
@@ -34,24 +34,10 @@
}
}
-.credit-card-label {
- &:before {
- @include sprite($credit-card);
- }
-
- .icon-input {
- padding-left: 45px;
- }
+select.month {
+ min-width: 50px;
}
-.lock-label {
- &:before {
- @include sprite($lock);
- }
-}
-
-.calendar-label {
- &:before {
- @include sprite($calendar);
- }
+select.year {
+ min-width: 70px;
}
diff --git a/app/styles/templates/project/thankyou.scss b/app/styles/templates/project/thankyou.scss
new file mode 100644
index 000000000..8dc38e5fd
--- /dev/null
+++ b/app/styles/templates/project/thankyou.scss
@@ -0,0 +1,14 @@
+.thankyou-container {
+ .icon, .header, .text, .project-link{
+ width: 100%;
+ text-align: center;
+ }
+
+ .icon img {
+ display: inline-block;
+ }
+
+ .text {
+ margin-bottom: 18px;
+ }
+}
diff --git a/app/templates/components/donation/card-item.hbs b/app/templates/components/donation/card-item.hbs
new file mode 100644
index 000000000..5b70cc722
--- /dev/null
+++ b/app/templates/components/donation/card-item.hbs
@@ -0,0 +1 @@
+{{card.brand}} ending in {{card.last4}}
\ No newline at end of file
diff --git a/app/templates/components/donation/card-list.hbs b/app/templates/components/donation/card-list.hbs
new file mode 100644
index 000000000..75f4d611a
--- /dev/null
+++ b/app/templates/components/donation/card-list.hbs
@@ -0,0 +1,7 @@
+{{#each cards as |card|}}
+ {{donation/card-item
+ card=card
+ select=(action selectCard card)
+ selectedCard=selectedCard
+ }}
+{{/each}}
diff --git a/app/templates/components/donation/credit-card.hbs b/app/templates/components/donation/credit-card.hbs
index 7c6edcb0c..7654956b3 100644
--- a/app/templates/components/donation/credit-card.hbs
+++ b/app/templates/components/donation/credit-card.hbs
@@ -1,4 +1,5 @@
Add card information
+
+
-
- Credit Card
- {{input
- type="text"
- class="icon-input"
- disabled=cannotDonate
- placeholder="Card number"}}
+
+ Card number
+ {{input name="card-number" type="text" value=cardNumber}}
-Donate
+
+ {{#if isSubmitting}}Adding...{{else}}Add Card{{/if}}
+
+
+{{#if canCancel}}
+ Cancel
+{{/if}}
+
+{{#unless isSubmitting}}
+ {{!validation errors can be passed in from parent, using a block}}
+ {{yield}}
+{{/unless}}
diff --git a/app/templates/components/donation/donation-container.hbs b/app/templates/components/donation/donation-container.hbs
index 1a107a34e..aa594e4e9 100644
--- a/app/templates/components/donation/donation-container.hbs
+++ b/app/templates/components/donation/donation-container.hbs
@@ -2,27 +2,38 @@
{{format-currency donationAmount}}
Payment information
-
- {{#if projectTitle}}
- Your payment method will be charged {{format-currency donationAmount}}
- per month to support {{projectTitle}}.
- {{else}}
- No project selected.
- {{/if}}
+
+ Your payment method will be charged {{format-currency donationAmount}}
+ per month to support {{projectTitle}}.
-{{donation/credit-card canDonate=canDonate}}
+{{#if canShowCardList}}
+ {{donation/card-list cards=cards selectedCard=defaultCard}}
+{{/if}}
-
-
- Your donation will repeat automatically each month.
- You can cancel or edit your donation at any time.
- By making this donation, you are agreeing to our
- {{link-to "Terms of use" "terms-of-use"}}.
-
-
+{{#if canAddCard}}
+ {{#donation/credit-card canSubmit=canSubmitAddCard submit=addCard canCancel=canShowCardList}}
+ {{!show errors}}
+ {{yield}}
+ {{/donation/credit-card}}
+{{/if}}
+
+{{#if canDonate}}
+ Donate
+
+ {{!show errors}}
+ {{yield}}
+
+
+
+ Your donation will repeat automatically each month.
+ You can cancel or edit your donation at any time.
+ By making this donation, you are agreeing to our
+ {{link-to "Terms of use" "terms-of-use"}}.
+
+
+{{/if}}
diff --git a/app/templates/components/donations/create-donation.hbs b/app/templates/components/donations/create-donation.hbs
index e720c4f9b..5dff34b47 100644
--- a/app/templates/components/donations/create-donation.hbs
+++ b/app/templates/components/donations/create-donation.hbs
@@ -1,16 +1,19 @@
{{#each presetAmounts as |presetAmount|}}
{{donations/donation-amount-button
- action=(action (mut amount))
+ customAmount=customAmount
presetAmount=presetAmount
- selectedAmount=amount}}
+ selectedAmount=amount
+ setCustomAmount=(action (mut customAmount))
+ setAmount=(action (mut amount))
+ }}
{{/each}}
$
- {{input class="amount" name="amount" type="number" min=0 placeholder="Custom amount" step=1 value=amount}}
+ {{input class="amount" name="amount" type="number" min=0 placeholder="Custom amount" step=1 value=customAmount}}
per month
-Continue
+Continue
diff --git a/app/templates/components/donations/donation-status.hbs b/app/templates/components/donations/donation-status.hbs
index 9d70978b9..5f64084d8 100644
--- a/app/templates/components/donations/donation-status.hbs
+++ b/app/templates/components/donations/donation-status.hbs
@@ -1,7 +1,7 @@
{{#if subscription}}
{{donations/show-donation amount=subscription.amount}}
{{else if processStarted}}
- {{donations/create-donation amount=15 continue=(action createDonation)}}
+ {{donations/create-donation continue=(action createDonation)}}
{{else}}
{{donations/become-a-donor becomeADonor=(action (mut processStarted) true)}}
{{/if}}
diff --git a/app/templates/components/error-formatter.hbs b/app/templates/components/error-formatter.hbs
index 92ac5b9a0..566782648 100644
--- a/app/templates/components/error-formatter.hbs
+++ b/app/templates/components/error-formatter.hbs
@@ -2,4 +2,4 @@
{{message}}
{{else}}
{{defaultMessage}}
-{{/each}}
\ No newline at end of file
+{{/each}}
diff --git a/app/templates/project/donate.hbs b/app/templates/project/donate.hbs
index 819128e9f..0b826e3bd 100644
--- a/app/templates/project/donate.hbs
+++ b/app/templates/project/donate.hbs
@@ -1,2 +1,13 @@
-{{project-header project=model}}
-{{donation/donation-container donationAmount=amount projectTitle=model.title}}
+{{project-header project=project}}
+{{#donation/donation-container
+ addCard=(action 'addCard')
+ cards=user.stripeCards
+ donate=(action 'donate' amount)
+ donationAmount=amount
+ isAddingCard=isAddingCard
+ projectTitle=project.title
+}}
+ {{#if error}}
+ {{error-formatter error=error}}
+ {{/if}}
+{{/donation/donation-container}}
diff --git a/app/templates/project/thankyou.hbs b/app/templates/project/thankyou.hbs
new file mode 100644
index 000000000..74fe61f62
--- /dev/null
+++ b/app/templates/project/thankyou.hbs
@@ -0,0 +1,8 @@
+{{project-header project=model}}
+
+
+
+
+
From all the volunteers on the {{model.title}} team.
+
{{#link-to "project" model}}Back to project{{/link-to}}
+
\ No newline at end of file
diff --git a/config/environment.js b/config/environment.js
index ddab714b3..7404cb700 100644
--- a/config/environment.js
+++ b/config/environment.js
@@ -78,6 +78,8 @@ module.exports = function(environment) {
sentry: {
dsn: 'https://cecdf7d399e74b72bc73dc8e4e62737d@app.getsentry.com/82741'
},
+
+ stripe: {}
};
if (environment === 'development') {
@@ -92,6 +94,8 @@ module.exports = function(environment) {
ENV.sentry.development = true;
+ ENV.stripe.publishableKey = 'pk_test_hiQ7tWKKdLSw8jJdE98NSW74';
+
ENV['ember-cli-mirage'] = {
enabled: false
};
@@ -158,6 +162,9 @@ module.exports = function(environment) {
ENV.sentry.development = true;
+ ENV.stripe.publishableKey = 'pk_test_hiQ7tWKKdLSw8jJdE98NSW74';
+ ENV.LOG_STRIPE_SERVICE = true,
+
ENV['simple-auth'] = {
store: 'simple-auth-session-store:ephemeral'
};
diff --git a/mirage/config.js b/mirage/config.js
index 47b630b17..52b7fe8b7 100644
--- a/mirage/config.js
+++ b/mirage/config.js
@@ -65,7 +65,8 @@ function generatePreviewMentions(schema, preview) {
const routes = [
'categories', 'comment-user-mentions', 'comments', 'donation-goals', 'organizations',
'task-user-mentions', 'tasks', 'previews', 'projects', 'project-categories',
- 'slugged-routes', 'stripe-subscriptions', 'user-categories', 'users'
+ 'slugged-routes', 'stripe-cards', 'stripe-customers', 'stripe-subscriptions',
+ 'user-categories', 'users'
];
export default function() {
@@ -401,6 +402,18 @@ export default function() {
this.post('/stripe-subscriptions');
+ /**
+ * Stripe cards
+ */
+
+ this.post('/stripe-cards');
+
+ /**
+ * Stripe customers
+ */
+
+ this.post('/stripe-customers');
+
/**
* Task user mentions
*/
diff --git a/mirage/models/stripe-card.js b/mirage/models/stripe-card.js
new file mode 100644
index 000000000..feceaa785
--- /dev/null
+++ b/mirage/models/stripe-card.js
@@ -0,0 +1,5 @@
+import { Model, belongsTo } from 'ember-cli-mirage';
+
+export default Model.extend({
+ user: belongsTo()
+});
diff --git a/mirage/models/stripe-customer.js b/mirage/models/stripe-customer.js
new file mode 100644
index 000000000..feceaa785
--- /dev/null
+++ b/mirage/models/stripe-customer.js
@@ -0,0 +1,5 @@
+import { Model, belongsTo } from 'ember-cli-mirage';
+
+export default Model.extend({
+ user: belongsTo()
+});
diff --git a/package.json b/package.json
index 6257ce3cc..987ab2f78 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"ember-simple-auth": "1.1.0",
"ember-simple-auth-token": "^1.2.0",
"ember-sinon": "0.5.1",
+ "ember-stripe-service": "4.4.2",
"ember-tether": "0.3.1",
"ember-tooltips": "2.4.0",
"ember-truth-helpers": "1.2.0",
diff --git a/tests/acceptance/project-donate-test.js b/tests/acceptance/project-donate-test.js
new file mode 100644
index 000000000..3660b1cec
--- /dev/null
+++ b/tests/acceptance/project-donate-test.js
@@ -0,0 +1,227 @@
+import { test } from 'qunit';
+import moduleForAcceptance from 'code-corps-ember/tests/helpers/module-for-acceptance';
+import Ember from 'ember';
+import Mirage from 'ember-cli-mirage';
+
+import { authenticateSession } from 'code-corps-ember/tests/helpers/ember-simple-auth';
+import createOrganizationWithSluggedRoute from 'code-corps-ember/tests/helpers/mirage/create-organization-with-slugged-route';
+import projectDonatePage from '../pages/project/donate';
+
+const {
+ RSVP,
+ Service
+} = Ember;
+
+// NOTE: Don't think these mocks can be moved, unless we can make them more generic than they are
+// As is, they mock specifically the `stripe` injection for the project.donate controller
+
+const stripeCardError = {
+ error: {
+ type: 'card_error',
+ code: 'invalid_expiry_year',
+ message: "Your card's expiration year is invalid.",
+ param: 'exp_year'
+ }
+};
+
+const stripeTokenResponse = {
+ id: 'tok_something', // Token identifier
+ card: { // Dictionary of the card used to create the token
+ name: null,
+ address_line1: '12 Main Street',
+ address_line2: 'Apt 42',
+ address_city: 'Palo Alto',
+ address_state: 'CA',
+ address_zip: '94301',
+ address_country: 'US',
+ country: 'US',
+ exp_month: 2,
+ exp_year: 2017,
+ last4: '4242',
+ object: 'card',
+ brand: 'Visa',
+ funding: 'credit'
+ },
+ created: 1478892395, // Timestamp of when token was created
+ livemode: true, // Whether this token was created with a live API key
+ type: 'card',
+ object: 'token', // Type of object, always "token"
+ used: false // Whether this token has been used
+};
+
+const stripeMockSuccess = {
+ card: {
+ createToken: () => RSVP.resolve(stripeTokenResponse)
+ }
+};
+
+const stripeMockFailure = {
+ card: {
+ createToken: () => RSVP.reject(stripeCardError)
+ }
+};
+
+function stubStripe(context, mock) {
+ let mockService = Service.create(mock);
+ context.application.__container__.lookup('controller:project.donate').set('stripe', mockService);
+}
+
+moduleForAcceptance('Acceptance | Project - Donate');
+
+test('It requires authentication', function(assert) {
+ assert.expect(1);
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectDonatePage.visit({
+ amount: 10,
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ assert.equal(currentRouteName(), 'login');
+ });
+});
+
+test('Allows adding a card and donating (creating a subscription)', function(assert) {
+ assert.expect(8);
+
+ stubStripe(this, stripeMockSuccess);
+
+ let user = server.create('user');
+ authenticateSession(this.application, { 'user_id': user.id });
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectDonatePage.visit({
+ amount: 10,
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242');
+ projectDonatePage.creditCard.cardCVC('123');
+ projectDonatePage.creditCard.cardMonth('12');
+ projectDonatePage.creditCard.cardYear('2020');
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.clickSubmit();
+ });
+
+ andThen(() => {
+ projectDonatePage.cardList.cards(0).clickCard();
+ projectDonatePage.clickDonate();
+ });
+
+ andThen(() => {
+ let customer = server.schema.stripeCustomers.findBy({ email: user.email });
+ assert.ok(customer, 'Customer was created with proper attributes.');
+ assert.equal(customer.userId, user.id, 'Customer was assigned to current user');
+
+ // we use attributes set in the mock stripe token response
+ let card = server.schema.stripeCards.findBy({ brand: 'Visa', last4: '4242', expMonth: 2, expYear: 2017 });
+ assert.ok(card, 'Card was created with proper attributes.');
+ assert.equal(card.userId, user.id, 'Card was assigned to current user');
+
+ // amount is 1000, in cents
+ let subscription = server.schema.stripeSubscriptions.findBy({ amount: 1000 });
+ assert.ok(subscription, 'Subscription was created sucessfully.');
+ assert.equal(subscription.userId, user.id, 'User was set to current user.');
+ assert.equal(subscription.projectId, project.id, 'Project was set to current project.');
+ assert.equal(currentRouteName(), 'project.thankyou', 'User was redirected to the thank you route.');
+ });
+});
+
+test('Shows stripe errors when creating card token fails', function(assert) {
+ assert.expect(3);
+
+ stubStripe(this, stripeMockFailure);
+
+ let user = server.create('user');
+ authenticateSession(this.application, { 'user_id': user.id });
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectDonatePage.visit({
+ amount: 10,
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242');
+ projectDonatePage.creditCard.cardCVC('123');
+ projectDonatePage.creditCard.cardMonth('12');
+ projectDonatePage.creditCard.cardYear('2020');
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.clickSubmit();
+ });
+
+ andThen(() => {
+ assert.equal(currentRouteName(), 'project.donate');
+ assert.equal(projectDonatePage.errorFormatter.errors().count, 1, 'Correct number of errors is displayed.');
+ assert.equal(projectDonatePage.errorFormatter.errors(0).message, stripeCardError.error.message, 'Correct error is displayed.');
+ });
+});
+
+test('Shows validation errors when creating subscription fails', function(assert) {
+ assert.expect(4);
+
+ stubStripe(this, stripeMockSuccess);
+
+ let user = server.create('user');
+ authenticateSession(this.application, { 'user_id': user.id });
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectDonatePage.visit({
+ amount: 0,
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.cardNumber('4242-4242-4242-4242');
+ projectDonatePage.creditCard.cardCVC('123');
+ projectDonatePage.creditCard.cardMonth('12');
+ projectDonatePage.creditCard.cardYear('2020');
+ });
+
+ andThen(() => {
+ projectDonatePage.creditCard.clickSubmit();
+ });
+
+ andThen(() => {
+ let done = assert.async();
+
+ server.post('/stripe-subscriptions', function() {
+ done();
+ return new Mirage.Response(422, {}, {
+ errors: [{
+ id: 'VALIDATION_ERROR',
+ source: { pointer: 'data/attributes/amount' },
+ detail: 'is invalid',
+ status: 422
+ }]
+ });
+ });
+ projectDonatePage.cardList.cards(0).clickCard();
+ projectDonatePage.clickDonate();
+ });
+
+ andThen(() => {
+ assert.notOk(server.schema.stripeSubscriptions.findBy({ amount: 1000 }), 'Subscription was not created.');
+ assert.equal(currentRouteName(), 'project.donate');
+ assert.equal(projectDonatePage.errorFormatter.errors().count, 1, 'Correct number of errors is displayed.');
+ assert.equal(projectDonatePage.errorFormatter.errors(0).message, 'Amount is invalid', 'Correct error is displayed.');
+ });
+});
diff --git a/tests/acceptance/project-thankyou-test.js b/tests/acceptance/project-thankyou-test.js
new file mode 100644
index 000000000..6cc8535c2
--- /dev/null
+++ b/tests/acceptance/project-thankyou-test.js
@@ -0,0 +1,52 @@
+import { test } from 'qunit';
+import moduleForAcceptance from 'code-corps-ember/tests/helpers/module-for-acceptance';
+
+import { authenticateSession } from 'code-corps-ember/tests/helpers/ember-simple-auth';
+import createOrganizationWithSluggedRoute from 'code-corps-ember/tests/helpers/mirage/create-organization-with-slugged-route';
+import projectThankYouPage from '../pages/project/thankyou';
+
+moduleForAcceptance('Acceptance | Project - ThankYou');
+
+test('It requires authentication', function(assert) {
+ assert.expect(1);
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectThankYouPage.visit({
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ assert.equal(currentRouteName(), 'login');
+ });
+});
+
+test('It renders project header', function(assert) {
+ assert.expect(5);
+
+ let user = server.create('user');
+ authenticateSession(this.application, { 'user_id': user.id });
+
+ let organization = createOrganizationWithSluggedRoute();
+ let project = server.create('project', { organization });
+
+ projectThankYouPage.visit({
+ organization: organization.slug,
+ project: project.slug
+ });
+
+ andThen(() => {
+ assert.ok(projectThankYouPage.rendersProjectHeader, 'Project header is rendered on the page.');
+ assert.ok(projectThankYouPage.rendersIcon, 'The success icon is rendered on the page.');
+ assert.ok(projectThankYouPage.rendersThankYouHeader, 'A thank you header is rendered on the page.');
+ assert.ok(projectThankYouPage.rendersText, 'A descriptive text is rendered on the page.');
+
+ projectThankYouPage.clickProjectLink();
+ });
+
+ andThen(() => {
+ assert.equal(currentRouteName(), 'project.index', 'Link leads to project page.');
+ });
+});
diff --git a/tests/acceptance/task-creation-test.js b/tests/acceptance/task-creation-test.js
index 48fe9e6e9..5f768d030 100644
--- a/tests/acceptance/task-creation-test.js
+++ b/tests/acceptance/task-creation-test.js
@@ -149,6 +149,7 @@ test('When task creation fails due to validation, validation errors are displaye
let taskCreationDone = assert.async();
server.post('/tasks', function() {
taskCreationDone();
+
return new Mirage.Response(422, {}, {
errors: [
{
diff --git a/tests/integration/components/donation/card-item-test.js b/tests/integration/components/donation/card-item-test.js
new file mode 100644
index 000000000..bee410188
--- /dev/null
+++ b/tests/integration/components/donation/card-item-test.js
@@ -0,0 +1,69 @@
+import { moduleForComponent, test } from 'ember-qunit';
+import Ember from 'ember';
+import hbs from 'htmlbars-inline-precompile';
+import PageObject from 'ember-cli-page-object';
+
+import cardItemComponent from '../../../pages/components/donation/card-item';
+
+let page = PageObject.create(cardItemComponent);
+
+const {
+ K
+} = Ember;
+
+let setHandler = function(context, selectHandler = K) {
+ context.set('selectHandler', selectHandler);
+};
+
+moduleForComponent('donation/card-item', 'Integration | Component | donation/card item', {
+ integration: true,
+ beforeEach() {
+ setHandler(this);
+ page.setContext(this);
+ },
+ afterEach() {
+ page.removeContext();
+ }
+});
+
+test('it renders proper information', function(assert) {
+ assert.expect(1);
+
+ this.set('card', { id: 1, brand: 'Visa', last4: 4242 });
+
+ page.render(hbs`{{donation/card-item card=card select=selectHandler}}`);
+
+ assert.equal(page.cardDescription, 'Visa ending in 4242', 'Card description is correct');
+});
+
+test('it allows selection of card', function(assert) {
+ assert.expect(1);
+
+ function selectHandler() {
+ assert.ok(true, 'Action got called.');
+ }
+
+ this.set('card', { id: 1, brand: 'Visa', last4: 4242 });
+
+ setHandler(this, selectHandler);
+
+ page.render(hbs`{{donation/card-item card=card select=selectHandler}}`)
+ .clickCard();
+});
+
+test('it shows card as selected if selected', function(assert) {
+ assert.expect(2);
+
+ let ourCard = { id: 1, brand: 'Visa', last4: 4242 };
+ let selectedCard = { id: 2, brand: 'Visa', last4: 4242 };
+ this.set('card', ourCard);
+ this.set('selectedCard', selectedCard);
+
+ page.render(hbs`{{donation/card-item card=card select=selectHandler selectedCard=selectedCard}}`);
+
+ assert.notOk(page.isSelected, 'Card is not displaying as selected.');
+
+ this.set('selectedCard', ourCard);
+
+ assert.ok(page.isSelected, 'Card is displaying as selected.');
+});
diff --git a/tests/integration/components/donation/card-list-test.js b/tests/integration/components/donation/card-list-test.js
new file mode 100644
index 000000000..699297e2c
--- /dev/null
+++ b/tests/integration/components/donation/card-list-test.js
@@ -0,0 +1,72 @@
+
+import { moduleForComponent, test } from 'ember-qunit';
+import Ember from 'ember';
+import hbs from 'htmlbars-inline-precompile';
+import PageObject from 'ember-cli-page-object';
+
+import cardListComponent from '../../../pages/components/donation/card-list';
+
+let page = PageObject.create(cardListComponent);
+
+const {
+ Object,
+ RSVP
+} = Ember;
+
+let setHandlers = function(context, { selectCardHandler = RSVP } = {}) {
+ context.set('selectCardHandler', selectCardHandler);
+};
+
+moduleForComponent('donation/card-list', 'Integration | Component | donation/card list', {
+ integration: true,
+ beforeEach() {
+ setHandlers(this);
+ page.setContext(this);
+ },
+ afterEach() {
+ page.removeContext();
+ }
+});
+
+test('it renders proper information', function(assert) {
+ assert.expect(3);
+
+ this.set('cards', [
+ { id: 1, brand: 'Visa', last4: '4242' },
+ { id: 2, brand: 'Diners', last4: '9999' }
+ ]);
+
+ page.render(hbs`{{donation/card-list cards=cards donate=donateHandler}}`);
+
+ assert.equal(page.cards().count, 2, 'Renders correct number of cards.');
+ assert.equal(page.cards(0).cardDescription, 'Visa ending in 4242', 'First card is rendered correctly.');
+ assert.equal(page.cards(1).cardDescription, 'Diners ending in 9999', 'Second card is rendered correctly.');
+});
+
+test('it allows user to select card', function(assert) {
+ assert.expect(4);
+
+ let visa = Object.create({ id: 1, brand: 'Visa', last4: '4242' });
+ let diners = Object.create({ id: 2, brand: 'Diners', last4: '9999' });
+ this.set('cards', [
+ visa, diners
+ ]);
+ this.set('selectedCard', visa);
+
+ function selectCardHandler(card) {
+ assert.deepEqual(card, visa, 'Card was passed correctly.');
+ return RSVP.resolve();
+ }
+
+ setHandlers(this, { selectCardHandler });
+
+ page.render(hbs`{{donation/card-list cards=cards selectedCard=selectedCard}}`);
+
+ assert.ok(page.cards(0).isSelected, 'First card is selected.');
+ assert.notOk(page.cards(1).isSelected, 'Second card is not selected.');
+
+ page.cards(1).clickCard();
+
+ assert.notOk(page.cards(0).isSelected, 'First card got unselected.');
+ assert.ok(page.cards(1).isSelected, 'Second card got selected.');
+});
diff --git a/tests/integration/components/donation/credit-card-test.js b/tests/integration/components/donation/credit-card-test.js
index 47ddfca37..541d974ab 100644
--- a/tests/integration/components/donation/credit-card-test.js
+++ b/tests/integration/components/donation/credit-card-test.js
@@ -1,29 +1,77 @@
import { moduleForComponent, test } from 'ember-qunit';
+import Ember from 'ember';
import hbs from 'htmlbars-inline-precompile';
+import PageObject from 'ember-cli-page-object';
+import stubService from 'code-corps-ember/tests/helpers/stub-service';
+import wait from 'ember-test-helpers/wait';
+
+import creditCardComponent from '../../../pages/components/donation/credit-card';
+
+let page = PageObject.create(creditCardComponent);
+
+const {
+ K,
+ RSVP,
+ run
+} = Ember;
+
+let setHandler = function(context, submitHandler = K) {
+ context.set('submitHandler', submitHandler);
+};
moduleForComponent('donation/credit-card', 'Integration | Component | donation/credit card', {
- integration: true
+ integration: true,
+ beforeEach() {
+ setHandler(this);
+ page.setContext(this);
+ },
+ afterEach() {
+ page.removeContext();
+ }
});
-test('inputs can be disabled', function(assert) {
+test('it sends submit with credit card fields when button is clicked', function(assert) {
+ let done = assert.async();
assert.expect(3);
- this.set('canDonate', false);
- this.render(hbs`{{donation/credit-card canDonate=canDonate}}`);
- let inputs = this.$().find('input').get();
+ this.set('canDonate', true);
- inputs.forEach((input) => {
- assert.ok($(input).prop('disabled'));
- });
-});
+ let expectedProps = {
+ cardNumber: '1234-5678-9012-3456',
+ cvc: '123',
+ month: '12',
+ year: '2020'
+ };
-test('inputs are enabled by default', function(assert) {
- assert.expect(3);
- this.render(hbs`{{donation/credit-card}}`);
+ let submitHandler = function(actualProps) {
+ return new RSVP.Promise((fulfill) => {
+ run.next(() => {
+ assert.deepEqual(actualProps, expectedProps, 'Action was called with proper attributes.');
+ assert.ok(page.submitDisabled, 'Submit button is disabled');
+ assert.equal(page.submitButtonText, 'Adding...', 'Submit button changed text');
+ fulfill();
+ });
+ });
+ };
+
+ setHandler(this, submitHandler);
+
+ stubService(this, 'stripe', {
+ card: {
+ validateCardNumber: () => true,
+ validateCVC: () => true,
+ validateExpiry: () => true
+ }
+ });
- let inputs = this.$().find('input').get();
+ page.render(hbs`{{donation/credit-card canDonate=canDonate submit=submitHandler}}`)
+ .cardNumber(expectedProps.cardNumber)
+ .cardMonth(expectedProps.month)
+ .cardYear(expectedProps.year)
+ .cardCVC(expectedProps.cvc)
+ .clickSubmit();
- inputs.forEach((input) => {
- assert.notOk($(input).prop('disabled'));
+ wait().then(() => {
+ done();
});
});
diff --git a/tests/integration/components/donation/donation-container-test.js b/tests/integration/components/donation/donation-container-test.js
index 7ada42cd3..9762ad0be 100644
--- a/tests/integration/components/donation/donation-container-test.js
+++ b/tests/integration/components/donation/donation-container-test.js
@@ -1,35 +1,130 @@
import { moduleForComponent, test } from 'ember-qunit';
+import donationContainerComponent from '../../../pages/components/donation/donation-container';
+import Ember from 'ember';
import hbs from 'htmlbars-inline-precompile';
+import PageObject from 'ember-cli-page-object';
+import stubService from 'code-corps-ember/tests/helpers/stub-service';
+
+let page = PageObject.create(donationContainerComponent);
+
+const {
+ K,
+ RSVP
+} = Ember;
+
+let mockStripeCard = {
+ brand: 'Visa',
+ exp_month: '12',
+ exp_year: '2020',
+ last4: '4242'
+};
+
+function setHandlers(context, { donateHandler = K, addCardHandler = RSVP } = {}) {
+ context.set('donateHandler', donateHandler);
+ context.set('addCardHandler', addCardHandler);
+}
moduleForComponent('donation/donation-container', 'Integration | Component | donation/donation container', {
- integration: true
+ integration: true,
+ beforeEach() {
+ setHandlers(this);
+ page.setContext(this);
+ },
+ afterEach() {
+ page.removeContext();
+ }
});
-test('it renders no project error when no project is passed in', function(assert) {
- assert.expect(1);
+test('it renders basic elements', function(assert) {
+ assert.expect(2);
+
+ this.set('amount', 100);
+ this.set('projectTitle', 'CodeCorps');
+
+ page.render(hbs`{{donation/donation-container donate=(action donateHandler) donationAmount=amount projectTitle=projectTitle}}`);
+
+ assert.equal(page.donationAmountText, '$100.00 given each month', 'Amount text is rendered correctly');
+ assert.equal(
+ page.paymentInformationText,
+ 'Your payment method will be charged $100.00 per month to support CodeCorps.',
+ 'Payment information renders correctly.'
+ );
+});
+
+test('it renders the right elements when adding a card and has existing cards', function(assert) {
+ assert.expect(2);
+
+ this.set('cards', [mockStripeCard]);
+
+ page.render(hbs`{{donation/donation-container cards=cards isAddingCard=true}}`);
- this.render(hbs`{{donation/donation-container}}`);
+ assert.ok(page.cardFormIsVisible, 'Credit card form component is rendered.');
+ assert.notOk(page.cardListIsVisible, 'Credit card list is not rendered.');
+});
+
+test('it renders the right elements when not adding a card and has existing cards', function(assert) {
+ assert.expect(3);
+
+ this.set('cards', [mockStripeCard]);
- let mainContent = this.$().find('p').eq(0);
- assert.equal(mainContent.text().trim(), 'No project selected.');
+ page.render(hbs`{{donation/donation-container canDonate=true cards=cards donate=(action donateHandler amount) isAddingCard=false}}`);
+
+ assert.notOk(page.cardFormIsVisible, 'Credit card form component is not rendered.');
+ assert.ok(page.cardListIsVisible, 'Credit card list is rendered.');
+ assert.ok(page.donationButtonIsVisible, 'Donation button renders');
});
-test('it renders donation amount and frequency', function(assert) {
+test('it handles adding a card correctly', function(assert) {
assert.expect(1);
- this.set('projectTitle', 'Funtown');
- this.render(hbs`{{donation/donation-container projectTitle=projectTitle}}`);
+ let cardDetails = {
+ cardNumber: '1234-5678-9012-3456',
+ cvc: '123',
+ month: '12',
+ year: '2020'
+ };
+
+ function addCardHandler(actualProps) {
+ assert.deepEqual(actualProps, cardDetails, 'Card parameters were passed correctly.');
+ return RSVP.resolve();
+ }
+
+ stubService(this, 'stripe', {
+ card: {
+ validateCardNumber: K,
+ validateCVC: K,
+ validateExpiry: K
+ }
+ });
- let mainContent = this.$().find('p').eq(0);
- assert.ok(mainContent.text().match('Your payment method will be charged'));
+ setHandlers(this, { addCardHandler });
+
+ page.render(hbs`{{donation/donation-container addCard=(action addCardHandler)}}`);
+
+ page.creditCard
+ .cardNumber(cardDetails.cardNumber)
+ .cardMonth(cardDetails.month)
+ .cardYear(cardDetails.year)
+ .cardCVC(cardDetails.cvc)
+ .clickSubmit();
});
-test('it renders donation amount and frequency', function(assert) {
+test('it handles donating correctly', function(assert) {
assert.expect(1);
- this.set('amount', 100); // in cents
- this.render(hbs`{{donation/donation-container donationAmount=amount}}`);
+ let amount = 100;
+ this.set('amount', amount);
+
+ this.set('cards', [mockStripeCard]);
+
+ function donateHandler(actualProps) {
+ assert.deepEqual(actualProps, amount, 'The proper amount is sent correctly.');
+ return RSVP.resolve();
+ }
+
+ setHandlers(this, { donateHandler });
+
+ page.render(hbs`{{donation/donation-container cards=cards donate=(action donateHandler amount) donationAmount=amount}} isAddingCard`);
- let donationInfo = this.$().find('h3').eq(0);
- assert.equal(donationInfo.text().trim(), '$100.00');
+ page.clickSubmit();
});
diff --git a/tests/integration/components/donations/create-donation-test.js b/tests/integration/components/donations/create-donation-test.js
index ec22f3bcb..ed1396957 100644
--- a/tests/integration/components/donations/create-donation-test.js
+++ b/tests/integration/components/donations/create-donation-test.js
@@ -39,22 +39,31 @@ test('it renders properly', function(assert) {
assert.ok(page.rendersContinueButton, 'Continue button is rendered.');
});
-test('preset amount buttons work', function(assert) {
- assert.expect(4);
+test('preset amount buttons unset the input value', function(assert) {
+ assert.expect(1);
page.render(hbs`{{donations/create-donation continue=continueHandler}}`);
page.setTo10.clickButton();
- assert.equal(page.customAmountValue, 10, 'Button for preset amount of 10 was clicked, so proper value should be set.');
+ assert.equal(page.customAmountValue, '', 'Button was clicked, so custom value should be unset.');
+});
+
+test('clicking "continue" calls action with present amount as argument', function(assert) {
+ assert.expect(1);
+
+ function continueHandler(amount) {
+ assert.equal(amount, 15, 'Proper amount was sent via action.');
+ }
+
+ setHandler(this, continueHandler);
+
+ page.render(hbs`{{donations/create-donation continue=continueHandler}}`);
+
page.setTo15.clickButton();
- assert.equal(page.customAmountValue, 15, 'Button for preset amount of 15 was clicked, so proper value should be set.');
- page.setTo25.clickButton();
- assert.equal(page.customAmountValue, 25, 'Button for preset amount of 25 was clicked, so proper value should be set.');
- page.setTo50.clickButton();
- assert.equal(page.customAmountValue, 50, 'Button for preset amount of 50 was clicked, so proper value should be set.');
+ page.clickContinue();
});
-test('clicking "continue" calls action with amount as argument', function(assert) {
+test('clicking "continue" calls action with custom amount as argument', function(assert) {
assert.expect(1);
function continueHandler(amount) {
@@ -69,12 +78,11 @@ test('clicking "continue" calls action with amount as argument', function(assert
page.clickContinue();
});
-test('buttons activate properly when custom amount is set to preset value', function(assert) {
+test('buttons do not activate when custom amount is set to preset value', function(assert) {
assert.expect(1);
page.render(hbs`{{donations/create-donation continue=continueHandler}}`);
- page.customAmount(22);
- page.customAmount(15);
- assert.ok(page.setTo15.isActive, 'Proper button should be active.');
+ page.customAmount(50);
+ assert.notOk(page.setTo50.isActive, 'Related button should not be active.');
});
diff --git a/tests/integration/components/donations/donation-amount-button-test.js b/tests/integration/components/donations/donation-amount-button-test.js
index f8a0703c5..045a8196a 100644
--- a/tests/integration/components/donations/donation-amount-button-test.js
+++ b/tests/integration/components/donations/donation-amount-button-test.js
@@ -11,14 +11,15 @@ const {
let page = PageObject.create(donationAmountButtonComponent);
-function setHandler(context, { actionHandler = K } = {}) {
- context.set('actionHandler', actionHandler);
+function setHandlers(context, { amountHandler = K, customAmountHandler = K } = {}) {
+ context.set('amountHandler', amountHandler);
+ context.set('customAmountHandler', customAmountHandler);
}
moduleForComponent('donations/donation-amount-button', 'Integration | Component | donations/donation amount button', {
integration: true,
beforeEach() {
- setHandler(this);
+ setHandlers(this);
page.setContext(this);
},
afterEach() {
@@ -26,23 +27,27 @@ moduleForComponent('donations/donation-amount-button', 'Integration | Component
}
});
-test('it sends action with specified amount when clicked', function(assert) {
- assert.expect(1);
+test('it sends actions when clicked', function(assert) {
+ assert.expect(2);
+
+ let amountHandler = function(amount) {
+ assert.equal(amount, 5, 'Proper amount was sent');
+ };
- let actionHandler = function(amount) {
- assert.equal(amount, 5, 'Proper amount was sent with action');
+ let customAmountHandler = function(customAmount) {
+ assert.equal(customAmount, null, 'A null custom amount was sent');
};
- setHandler(this, { actionHandler });
+ setHandlers(this, { amountHandler, customAmountHandler });
- page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5}}`)
- .clickButton();
+ page.render(hbs`{{donations/donation-amount-button setAmount=amountHandler setCustomAmount=customAmountHandler presetAmount=5}}`);
+ page.clickButton();
});
test('it shows as active if button is selected', function(assert) {
assert.expect(1);
- page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5 selectedAmount=5}}`);
+ page.render(hbs`{{donations/donation-amount-button setAmount=actionHandler presetAmount=5 selectedAmount=5}}`);
assert.ok(page.isActive, 'Button is rendered as active');
});
@@ -50,7 +55,7 @@ test('it shows as active if button is selected', function(assert) {
test('it shows as inactive if button is not selected', function(assert) {
assert.expect(1);
- page.render(hbs`{{donations/donation-amount-button action=actionHandler presetAmount=5 selectedAmount=10}}`);
+ page.render(hbs`{{donations/donation-amount-button setAmount=actionHandler presetAmount=5 selectedAmount=10}}`);
assert.ok(page.isInactive, 'Button is rendered as inActive');
});
diff --git a/tests/integration/components/error-formatter-test.js b/tests/integration/components/error-formatter-test.js
index a3f43fb14..e8abf3219 100644
--- a/tests/integration/components/error-formatter-test.js
+++ b/tests/integration/components/error-formatter-test.js
@@ -1,42 +1,88 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
-import Ember from 'ember';
+import PageObject from 'ember-cli-page-object';
-const { Object } = Ember;
+import errorFormatterComponent from '../../pages/components/error-formatter';
-moduleForComponent('error-formatter', 'Integration | Component | error formatter', {
- integration: true
-});
-
-test('it renders', function(assert) {
- assert.expect(1);
+let page = PageObject.create(errorFormatterComponent);
- this.render(hbs`{{error-formatter}}`);
- assert.equal(this.$('.error-formatter').length, 1, "The component's element renders");
+moduleForComponent('error-formatter', 'Integration | Component | error formatter', {
+ integration: true,
+ beforeEach() {
+ page.setContext(this);
+ },
+ afterEach() {
+ page.removeContext();
+ }
});
-let mockResponseWithMultipleErrors = Object.create({
+const mockAdapterError = {
+ isAdapterError: true,
errors: [
{ title: 'First', detail: 'error' },
{ title: 'Second', detail: 'error' }
]
+};
+
+const mockValidationErrors = {
+ isAdapterError: true,
+ errors: [
+ {
+ id: 'VALIDATION_ERROR',
+ source: { pointer: 'data/attributes/amount' },
+ detail: 'is all wrong',
+ status: 422
+ }, {
+ id: 'VALIDATION_ERROR',
+ source: { pointer: 'data/attributes/description' },
+ detail: "is bad, m'kay?",
+ status: 422
+ }
+ ]
+};
+
+const mockStripeError = {
+ error: {
+ type: 'card_error',
+ code: 'invalid_expiry_year',
+ message: "Your card's expiration year is invalid.",
+ param: 'exp_year'
+ }
+};
+
+test('it formats adapter error properly', function(assert) {
+ assert.expect(3);
+
+ this.set('error', mockAdapterError);
+ page.render(hbs`{{error-formatter error=error}}`);
+ assert.equal(page.errors().count, 2, 'Each error message is rendered');
+ assert.equal(page.errors(0).text, 'First: error', 'First message is rendered');
+ assert.equal(page.errors(1).text, 'Second: error', 'Second message is rendered');
});
-test('it displays a message for each error in the response', function(assert) {
+test('it formats adapter validation error properly', function(assert) {
assert.expect(3);
- this.set('error', mockResponseWithMultipleErrors);
- this.render(hbs`{{error-formatter error=error}}`);
- assert.equal(this.$('.error-formatter .error').length, 2, 'Each error message is rendered');
- assert.equal(this.$('.error-formatter .error:eq(0)').text().trim(), 'First: error', 'First message is rendered');
- assert.equal(this.$('.error-formatter .error:eq(1)').text().trim(), 'Second: error', 'Second message is rendered');
+ this.set('error', mockValidationErrors);
+ page.render(hbs`{{error-formatter error=error}}`);
+ assert.equal(page.errors().count, 2, 'Each error message is rendered');
+ assert.equal(page.errors(0).text, 'Amount is all wrong', 'First message is rendered');
+ assert.equal(page.errors(1).text, "Description is bad, m'kay?", 'Second message is rendered');
+});
+
+test('it formats stripe card error properly', function(assert) {
+ assert.expect(1);
+
+ this.set('error', mockStripeError);
+ page.render(hbs`{{error-formatter error=error}}`);
+ assert.equal(page.errors(0).text, mockStripeError.error.message, 'Message is rendered');
});
-test('it displays a default message if there are no errors in the response', function(assert) {
+test('it displays a default message if the error structure is not supported', function(assert) {
assert.expect(2);
this.set('error', {});
- this.render(hbs`{{error-formatter error=error}}`);
- assert.equal(this.$('.error-formatter .error').length, 1, 'Each error message is rendered');
- assert.equal(this.$('.error-formatter .error:eq(0)').text().trim(), 'An unexpected error has occured', 'Default message is rendered');
+ page.render(hbs`{{error-formatter error=error}}`);
+ assert.equal(page.errors().count, 1, 'Each error message is rendered');
+ assert.equal(page.errors(0).text, 'An unexpected error has occured', 'Default message is rendered');
});
diff --git a/tests/pages/components/donation/card-item.js b/tests/pages/components/donation/card-item.js
new file mode 100644
index 000000000..7ccdb15b0
--- /dev/null
+++ b/tests/pages/components/donation/card-item.js
@@ -0,0 +1,9 @@
+import { clickable, hasClass, text } from 'ember-cli-page-object';
+
+export default {
+ scope: '.card-item',
+
+ cardDescription: text(''),
+ clickCard: clickable(''),
+ isSelected: hasClass('selected', '')
+};
diff --git a/tests/pages/components/donation/card-list.js b/tests/pages/components/donation/card-list.js
new file mode 100644
index 000000000..6618b598c
--- /dev/null
+++ b/tests/pages/components/donation/card-list.js
@@ -0,0 +1,11 @@
+import { collection } from 'ember-cli-page-object';
+import cardItem from './card-item';
+
+export default {
+ scope: '.card-list',
+
+ cards: collection({
+ itemScope: '.card-item',
+ item: cardItem
+ })
+};
diff --git a/tests/pages/components/donation/credit-card.js b/tests/pages/components/donation/credit-card.js
new file mode 100644
index 000000000..6b3437708
--- /dev/null
+++ b/tests/pages/components/donation/credit-card.js
@@ -0,0 +1,15 @@
+import { clickable, fillable, is, selectable, text } from 'ember-cli-page-object';
+
+export default {
+ scope: '.credit-card-form',
+
+ cardCVC: fillable('[name=card-cvc]'),
+ cardNumber: fillable('[name=card-number]'),
+ cardMonth: selectable('select.month'),
+ cardYear: selectable('select.year'),
+
+ clickSubmit: clickable('button'),
+
+ submitButtonText: text('button'),
+ submitDisabled: is(':disabled', 'button')
+};
diff --git a/tests/pages/components/donation/donation-container.js b/tests/pages/components/donation/donation-container.js
new file mode 100644
index 000000000..af8828f6c
--- /dev/null
+++ b/tests/pages/components/donation/donation-container.js
@@ -0,0 +1,19 @@
+import { clickable, isVisible, text } from 'ember-cli-page-object';
+import creditCard from './credit-card';
+import cardList from './card-list';
+
+export default {
+ scope: '.donation-container',
+
+ cardList,
+ creditCard,
+
+ cardFormIsVisible: isVisible('.credit-card-form'),
+ cardListIsVisible: isVisible('.card-list'),
+ donationButtonIsVisible: isVisible('button.donate'),
+
+ clickSubmit: clickable('button.donate'),
+
+ donationAmountText: text('.donation-amount'),
+ paymentInformationText: text('.payment-information')
+};
diff --git a/tests/pages/components/error-formatter.js b/tests/pages/components/error-formatter.js
new file mode 100644
index 000000000..9bbe01af1
--- /dev/null
+++ b/tests/pages/components/error-formatter.js
@@ -0,0 +1,13 @@
+import { collection, text } from 'ember-cli-page-object';
+
+export default {
+ scope: '.error-formatter',
+
+ errors: collection({
+ itemScope: '.error',
+
+ item: {
+ message: text()
+ }
+ })
+};
diff --git a/tests/pages/project/donate.js b/tests/pages/project/donate.js
new file mode 100644
index 000000000..bddae7af6
--- /dev/null
+++ b/tests/pages/project/donate.js
@@ -0,0 +1,15 @@
+import { clickable, create, is, visitable } from 'ember-cli-page-object';
+import cardList from '../components/donation/card-list';
+import creditCard from '../components/donation/credit-card';
+import errorFormatter from '../components/error-formatter';
+
+export default create({
+ visit: visitable(':organization/:project/donate'),
+
+ clickDonate: clickable('button.donate'),
+ donateButtonIsDisabled: is(':disabled', 'button.donate'),
+
+ cardList,
+ creditCard,
+ errorFormatter
+});
diff --git a/tests/pages/project/thankyou.js b/tests/pages/project/thankyou.js
new file mode 100644
index 000000000..2bc076ac5
--- /dev/null
+++ b/tests/pages/project/thankyou.js
@@ -0,0 +1,12 @@
+import { clickable, create, isVisible, visitable } from 'ember-cli-page-object';
+
+export default create({
+ visit: visitable(':organization/:project/thankyou'),
+
+ clickProjectLink: clickable('.project-link a'),
+
+ rendersProjectHeader: isVisible('.project-header'),
+ rendersIcon: isVisible('.icon img'),
+ rendersThankYouHeader: isVisible('.header'),
+ rendersText: isVisible('.text')
+});
diff --git a/tests/unit/models/stripe-card-test.js b/tests/unit/models/stripe-card-test.js
new file mode 100644
index 000000000..e90c09a21
--- /dev/null
+++ b/tests/unit/models/stripe-card-test.js
@@ -0,0 +1,14 @@
+import { moduleForModel, test } from 'ember-qunit';
+
+moduleForModel('stripe-card', 'Unit | Model | stripe card', {
+ // Specify the other units that are required for this test.
+ needs: [
+ 'model:user'
+ ]
+});
+
+test('it exists', function(assert) {
+ let model = this.subject();
+ // let store = this.store();
+ assert.ok(!!model);
+});
diff --git a/tests/unit/models/stripe-customer-test.js b/tests/unit/models/stripe-customer-test.js
new file mode 100644
index 000000000..a58674b7f
--- /dev/null
+++ b/tests/unit/models/stripe-customer-test.js
@@ -0,0 +1,14 @@
+import { moduleForModel, test } from 'ember-qunit';
+
+moduleForModel('stripe-customer', 'Unit | Model | stripe customer', {
+ // Specify the other units that are required for this test.
+ needs: [
+ 'model:user'
+ ]
+});
+
+test('it exists', function(assert) {
+ let model = this.subject();
+ // let store = this.store();
+ assert.ok(!!model);
+});
diff --git a/tests/unit/models/user-test.js b/tests/unit/models/user-test.js
index 484f0cf72..0ede77546 100644
--- a/tests/unit/models/user-test.js
+++ b/tests/unit/models/user-test.js
@@ -5,6 +5,8 @@ import { testForHasMany } from '../../helpers/relationship';
moduleForModel('user', 'Unit | Model | user', {
needs: [
'model:organization-membership',
+ 'model:stripe-card',
+ 'model:stripe-customer',
'model:stripe-subscription',
'model:user-category',
'model:user-role',
diff --git a/tests/unit/routes/project/donate-test.js b/tests/unit/routes/project/donate-test.js
new file mode 100644
index 000000000..19aa0e3b1
--- /dev/null
+++ b/tests/unit/routes/project/donate-test.js
@@ -0,0 +1,10 @@
+import { moduleFor, test } from 'ember-qunit';
+
+moduleFor('route:project/donate', 'Unit | Route | project/donate', {
+ needs: ['service:metrics']
+});
+
+test('it exists', function(assert) {
+ let route = this.subject();
+ assert.ok(route);
+});
diff --git a/tests/unit/routes/project/thankyou-test.js b/tests/unit/routes/project/thankyou-test.js
new file mode 100644
index 000000000..2279468af
--- /dev/null
+++ b/tests/unit/routes/project/thankyou-test.js
@@ -0,0 +1,10 @@
+import { moduleFor, test } from 'ember-qunit';
+
+moduleFor('route:project/thankyou', 'Unit | Route | project/thankyou', {
+ needs: ['service:metrics']
+});
+
+test('it exists', function(assert) {
+ let route = this.subject();
+ assert.ok(route);
+});