Add ability to view and change the credit card associated with an account
|
@ -1218,6 +1218,80 @@ def subscription_view(stripe_subscription, used_repos):
|
|||
}
|
||||
|
||||
|
||||
@app.route('/api/user/card', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_user_card_api():
|
||||
user = current_user.db_user()
|
||||
return jsonify(get_card(user))
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/card', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_org_card_api(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
return jsonify(get_card(organization))
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/user/card', methods=['POST'])
|
||||
@api_login_required
|
||||
def set_user_card_api():
|
||||
user = current_user.db_user()
|
||||
token = request.get_json()['token']
|
||||
return jsonify(set_card(user, token))
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/card', methods=['POST'])
|
||||
@api_login_required
|
||||
def set_org_card_api(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
token = request.get_json()['token']
|
||||
return jsonify(set_card(organization, token))
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def set_card(user, token):
|
||||
print token
|
||||
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
if cus:
|
||||
cus.card = token
|
||||
cus.save()
|
||||
|
||||
return get_card(user)
|
||||
|
||||
|
||||
def get_card(user):
|
||||
card_info = {
|
||||
'is_valid': False
|
||||
}
|
||||
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
if cus and cus.default_card:
|
||||
# Find the default card.
|
||||
default_card = None
|
||||
for card in cus.cards.data:
|
||||
if card.id == cus.default_card:
|
||||
default_card = card
|
||||
break
|
||||
|
||||
if default_card:
|
||||
card_info = {
|
||||
'owner': card.name,
|
||||
'type': card.type,
|
||||
'last4': card.last4
|
||||
}
|
||||
|
||||
return {'card': card_info}
|
||||
|
||||
@app.route('/api/user/plan', methods=['PUT'])
|
||||
@api_login_required
|
||||
def subscribe_api():
|
||||
|
|
|
@ -6,9 +6,39 @@
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.billing-options-element .current-card .no-card-outline {
|
||||
display: inline-block;
|
||||
width: 73px;
|
||||
height: 44px;
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
border: 1px dashed #aaa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card .last4 {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card .last4 b {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.billing-options-element .current-card img {
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
padding: 4px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-option label {
|
||||
|
|
|
@ -1,4 +1,27 @@
|
|||
<div class="billing-options-element">
|
||||
<div class="billing-options-element">
|
||||
<!-- Credit Card -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
Credit Card
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<i class="fa fa-spinner fa-spin fa-2x" ng-show="!currentCard || changingCard"></i>
|
||||
<div class="current-card" ng-show="currentCard && !changingCard">
|
||||
<img src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
||||
|
||||
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
||||
<span class="not-found" ng-show="!currentCard.last4">No credit card found</span>
|
||||
</div>
|
||||
|
||||
<button class="btn" ng-class="currentCard.last4 ? 'btn-default' : 'btn-primary'"
|
||||
ng-show="currentCard && !changingCard" ng-click="changeCard()">
|
||||
Change Credit Card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
Billing Options
|
||||
|
|
BIN
static/img/creditcards/amex.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
static/img/creditcards/credit.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
static/img/creditcards/dankort.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
static/img/creditcards/diners.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
static/img/creditcards/discover.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
static/img/creditcards/forbru.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/creditcards/google.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
static/img/creditcards/jcb.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
static/img/creditcards/laser.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
static/img/creditcards/maestro.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
static/img/creditcards/mastercard.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
static/img/creditcards/money.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
static/img/creditcards/paypa.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
static/img/creditcards/shopify.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
static/img/creditcards/solo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
static/img/creditcards/visa.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
161
static/js/app.js
|
@ -122,8 +122,22 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
$provide.factory('PlanService', ['Restangular', 'KeyService', 'UserService', function(Restangular, KeyService, UserService) {
|
||||
var plans = null;
|
||||
var planDict = {};
|
||||
var planService = {}
|
||||
var planService = {};
|
||||
var listeners = [];
|
||||
|
||||
planService.registerListener = function(obj, callback) {
|
||||
listeners.push({'obj': obj, 'callback': callback});
|
||||
};
|
||||
|
||||
planService.unregisterListener = function(obj) {
|
||||
for (var i = 0; i < listeners.length; ++i) {
|
||||
if (listeners[i].obj == obj) {
|
||||
listeners.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
planService.verifyLoaded = function(callback) {
|
||||
if (plans) {
|
||||
callback(plans);
|
||||
|
@ -192,8 +206,8 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
});
|
||||
};
|
||||
|
||||
planService.getSubscription = function(organization, success, failure) {
|
||||
var url = planService.getSubscriptionUrl(organization);
|
||||
planService.getSubscription = function(orgname, success, failure) {
|
||||
var url = planService.getSubscriptionUrl(orgname);
|
||||
var getSubscription = Restangular.one(url);
|
||||
getSubscription.get().then(success, failure);
|
||||
};
|
||||
|
@ -213,7 +227,14 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
|
||||
var url = planService.getSubscriptionUrl(orgname);
|
||||
var createSubscriptionRequest = Restangular.one(url);
|
||||
createSubscriptionRequest.customPUT(subscriptionDetails).then(success, failure);
|
||||
createSubscriptionRequest.customPUT(subscriptionDetails).then(function(resp) {
|
||||
success(resp);
|
||||
planService.getPlan(planId, function(plan) {
|
||||
for (var i = 0; i < listeners.length; ++i) {
|
||||
listeners[i]['callback'](plan);
|
||||
}
|
||||
});
|
||||
}, failure);
|
||||
};
|
||||
|
||||
planService.changePlan = function($scope, orgname, planId, hasExistingSubscription, callbacks) {
|
||||
|
@ -228,6 +249,58 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure']);
|
||||
};
|
||||
|
||||
planService.changeCreditCard = function($scope, orgname, callbacks) {
|
||||
if (callbacks['opening']) {
|
||||
callbacks['opening']();
|
||||
}
|
||||
|
||||
var submitToken = function(token) {
|
||||
$scope.$apply(function() {
|
||||
if (callbacks['started']) {
|
||||
callbacks['started']();
|
||||
}
|
||||
|
||||
var cardInfo = {
|
||||
'token': token.id
|
||||
};
|
||||
|
||||
var url = orgname ? getRestUrl('organization', orgname, 'card') : 'user/card';
|
||||
var changeCardRequest = Restangular.one(url);
|
||||
changeCardRequest.customPOST(cardInfo).then(callbacks['success'], callbacks['failure']);
|
||||
});
|
||||
};
|
||||
|
||||
var email = planService.getEmail(orgname);
|
||||
StripeCheckout.open({
|
||||
key: KeyService.stripePublishableKey,
|
||||
address: false,
|
||||
email: email,
|
||||
currency: 'usd',
|
||||
name: 'Update credit card',
|
||||
description: 'Enter your credit card number',
|
||||
panelLabel: 'Update',
|
||||
token: submitToken,
|
||||
image: 'static/img/quay-icon-stripe.png',
|
||||
opened: function() { $scope.$apply(function() { callbacks['opened']() }); },
|
||||
closed: function() { $scope.$apply(function() { callbacks['closed']() }); }
|
||||
});
|
||||
};
|
||||
|
||||
planService.getEmail = function(orgname) {
|
||||
var email = null;
|
||||
if (UserService.currentUser()) {
|
||||
email = UserService.currentUser().email;
|
||||
|
||||
if (orgname) {
|
||||
org = UserService.getOrganization(orgname);
|
||||
if (org) {
|
||||
emaiil = org.email;
|
||||
}
|
||||
}
|
||||
}
|
||||
return email;
|
||||
};
|
||||
|
||||
planService.showSubscribeDialog = function($scope, orgname, planId, callbacks) {
|
||||
if (callbacks['opening']) {
|
||||
callbacks['opening']();
|
||||
|
@ -245,18 +318,7 @@ quayApp = angular.module('quay', ['restangular', 'angularMoment', 'angulartics',
|
|||
};
|
||||
|
||||
planService.getPlan(planId, function(planDetails) {
|
||||
var email = null;
|
||||
if (UserService.currentUser()) {
|
||||
email = UserService.currentUser().email;
|
||||
|
||||
if (orgname) {
|
||||
org = UserService.getOrganization(orgname);
|
||||
if (org) {
|
||||
emaiil = org.email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var email = planService.getEmail(orgname);
|
||||
StripeCheckout.open({
|
||||
key: KeyService.stripePublishableKey,
|
||||
address: false,
|
||||
|
@ -632,13 +694,71 @@ quayApp.directive('billingOptions', function () {
|
|||
'user': '=user',
|
||||
'organization': '=organization'
|
||||
},
|
||||
controller: function($scope, $element, Restangular) {
|
||||
controller: function($scope, $element, PlanService, Restangular) {
|
||||
$scope.invoice_email = false;
|
||||
$scope.currentCard = null;
|
||||
|
||||
// Listen to plan changes.
|
||||
PlanService.registerListener(this, function(plan) {
|
||||
if (plan && plan.price > 0) {
|
||||
update();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
PlanService.unregisterListener(this);
|
||||
});
|
||||
|
||||
$scope.changeCard = function() {
|
||||
$scope.changingCard = true;
|
||||
var callbacks = {
|
||||
'opened': function() { $scope.changingCard = true; },
|
||||
'closed': function() { $scope.changingCard = false; },
|
||||
'started': function() { $scope.currentCard = null; },
|
||||
'success': function(resp) {
|
||||
$scope.currentCard = resp.card;
|
||||
$scope.changingCard = false;
|
||||
},
|
||||
'failure': function() {
|
||||
$('#couldnotchangecardModal').modal({});
|
||||
$scope.changingCard = false;
|
||||
}
|
||||
};
|
||||
|
||||
PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks);
|
||||
};
|
||||
|
||||
$scope.getCreditImage = function(creditInfo) {
|
||||
if (!creditInfo) { return 'credit.png'; }
|
||||
|
||||
var kind = creditInfo.type.toLowerCase() || 'credit';
|
||||
var supported = {
|
||||
'american express': 'amex',
|
||||
'credit': 'credit',
|
||||
'diners club': 'diners',
|
||||
'discover': 'discover',
|
||||
'jcb': 'jcb',
|
||||
'mastercard': 'mastercard',
|
||||
'visa': 'visa'
|
||||
};
|
||||
|
||||
kind = supported[kind] || 'credit';
|
||||
return kind + '.png';
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.user && !$scope.organization) { return; }
|
||||
$scope.obj = $scope.user ? $scope.user : $scope.organization;
|
||||
$scope.invoice_email = $scope.obj.invoice_email;
|
||||
|
||||
// Load the credit card information.
|
||||
var url = $scope.organization ?
|
||||
getRestUrl('organization', $scope.organization.name, 'card') : 'user/card';
|
||||
|
||||
var getCard = Restangular.one(url);
|
||||
getCard.customGET().then(function(resp) {
|
||||
$scope.currentCard = resp.card;
|
||||
});
|
||||
};
|
||||
|
||||
var save = function() {
|
||||
|
@ -677,7 +797,8 @@ quayApp.directive('planManager', function () {
|
|||
scope: {
|
||||
'user': '=user',
|
||||
'organization': '=organization',
|
||||
'readyForPlan': '&readyForPlan'
|
||||
'readyForPlan': '&readyForPlan',
|
||||
'planChanged': '&planChanged'
|
||||
},
|
||||
controller: function($scope, $element, PlanService, Restangular) {
|
||||
var hasSubscription = false;
|
||||
|
@ -715,6 +836,10 @@ quayApp.directive('planManager', function () {
|
|||
PlanService.getPlan(sub.plan, function(subscribedPlan) {
|
||||
$scope.subscribedPlan = subscribedPlan;
|
||||
$scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos;
|
||||
|
||||
if ($scope.planChanged) {
|
||||
$scope.planChanged({ 'plan': subscribedPlan });
|
||||
}
|
||||
|
||||
if (sub.usedPrivateRepos > $scope.subscribedPlan.privateRepos) {
|
||||
$scope.limit = 'over';
|
||||
|
|
|
@ -728,6 +728,10 @@ function UserAdminCtrl($scope, $timeout, $location, Restangular, PlanService, Us
|
|||
|
||||
$('.form-change-pw').popover();
|
||||
|
||||
$scope.planChanged = function(plan) {
|
||||
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||
};
|
||||
|
||||
$scope.showConvertForm = function() {
|
||||
PlanService.getMatchingBusinessPlan(function(plan) {
|
||||
$scope.org.plan = plan;
|
||||
|
@ -1206,6 +1210,10 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService
|
|||
$scope.membersFound = null;
|
||||
$scope.invoiceLoading = true;
|
||||
|
||||
$scope.planChanged = function(plan) {
|
||||
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||
};
|
||||
|
||||
$scope.loadInvoices = function() {
|
||||
if ($scope.invoices) { return; }
|
||||
$scope.invoiceLoading = true;
|
||||
|
|
|
@ -15,7 +15,8 @@
|
|||
<ul class="nav nav-pills nav-stacked">
|
||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing</a></li>
|
||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing</a></li>
|
||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
@ -24,15 +25,18 @@
|
|||
<div class="tab-content">
|
||||
<!-- Plans tab -->
|
||||
<div id="plan" class="tab-pane active">
|
||||
<div class="plan-manager" organization="orgname"></div>
|
||||
<div class="plan-manager" organization="orgname" plan-changed="planChanged(plan)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Billing tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<!-- Billing Options tab -->
|
||||
<div id="billingoptions" class="tab-pane">
|
||||
<div class="billing-options" organization="organization"></div>
|
||||
</div>
|
||||
|
||||
<!-- Billing History tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div ng-show="invoiceLoading">
|
||||
Loading billing history: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i>
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div ng-show="!invoiceLoading && !invoices">
|
||||
|
|
|
@ -27,8 +27,8 @@
|
|||
<div class="col-md-2">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -38,7 +38,7 @@
|
|||
<div class="tab-content">
|
||||
<!-- Plans tab -->
|
||||
<div id="plan" class="tab-pane active">
|
||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()"></div>
|
||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Change password tab -->
|
||||
|
|