diff --git a/application.py b/application.py index d3feefcb0..706c64a8c 100644 --- a/application.py +++ b/application.py @@ -11,6 +11,7 @@ import endpoints.api import endpoints.web import endpoints.tags import endpoints.registry +import endpoints.webhooks logger = logging.getLogger(__name__) diff --git a/data/database.py b/data/database.py index fc5a68454..1dd51788a 100644 --- a/data/database.py +++ b/data/database.py @@ -34,6 +34,7 @@ class User(BaseModel): verified = BooleanField(default=False) stripe_id = CharField(index=True, null=True) organization = BooleanField(default=False, index=True) + invoice_email = BooleanField(default=False) class TeamRole(BaseModel): diff --git a/data/model.py b/data/model.py index cfbb9340c..ea42ef7c9 100644 --- a/data/model.py +++ b/data/model.py @@ -296,6 +296,12 @@ def get_user(username): return None +def get_user_or_org_by_customer_id(customer_id): + try: + return User.get(User.stripe_id == customer_id) + except User.DoesNotExist: + return None + def get_matching_teams(team_prefix, organization): query = Team.select().where(Team.name ** (team_prefix + '%'), Team.organization == organization) @@ -491,6 +497,11 @@ def change_password(user, new_password): user.save() +def change_invoice_email(user, invoice_email): + user.invoice_email = invoice_email + user.save() + + def update_email(user, new_email): user.email = new_email user.verified = False diff --git a/endpoints/api.py b/endpoints/api.py index 8f93bd0d8..1fee46686 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -71,8 +71,7 @@ def plans_list(): }) -@app.route('/api/user/', methods=['GET']) -def get_logged_in_user(): +def user_view(user): def org_view(o): admin_org = AdministerOrganizationPermission(o.username) return { @@ -82,16 +81,9 @@ def get_logged_in_user(): 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can() } - if current_user.is_anonymous(): - return jsonify({'anonymous': True}) - - user = current_user.db_user() - if not user or user.organization: - return jsonify({'anonymous': True}) - organizations = model.get_user_organizations(user.username) - return jsonify({ + return { 'verified': user.verified, 'anonymous': False, 'username': user.username, @@ -99,8 +91,21 @@ def get_logged_in_user(): 'gravatar': compute_hash(user.email), 'askForPassword': user.password_hash is None, 'organizations': [org_view(o) for o in organizations], - 'can_create_repo': True - }) + 'can_create_repo': True, + 'invoice_email': user.invoice_email + } + + +@app.route('/api/user/', methods=['GET']) +def get_logged_in_user(): + if current_user.is_anonymous(): + return jsonify({'anonymous': True}) + + user = current_user.db_user() + if not user or user.organization: + return jsonify({'anonymous': True}) + + return jsonify(user_view(user)) @app.route('/api/user/convert', methods=['POST']) @@ -150,6 +155,11 @@ def change_user_details(): if 'password' in user_data: logger.debug('Changing password for user: %s', user.username) model.change_password(user, user_data['password']) + + if 'invoice_email' in user_data: + logger.debug('Changing invoice_email for user: %s', user.username) + model.change_invoice_email(user, user_data['invoice_email']) + except model.InvalidPasswordException, ex: error_resp = jsonify({ 'message': ex.message, @@ -157,14 +167,7 @@ def change_user_details(): error_resp.status_code = 400 return error_resp - return jsonify({ - 'verified': user.verified, - 'anonymous': False, - 'username': user.username, - 'email': user.email, - 'gravatar': compute_hash(user.email), - 'askForPassword': user.password_hash is None, - }) + return jsonify(user_view(user)) @app.route('/api/user/', methods=['POST']) @@ -340,6 +343,23 @@ def create_organization_api(): return error_resp +def org_view(o, teams): + admin_org = AdministerOrganizationPermission(o.username) + is_admin = admin_org.can() + view = { + 'name': o.username, + 'email': o.email if is_admin else '', + 'gravatar': compute_hash(o.email), + 'teams': {t.name : team_view(o.username, t) for t in teams}, + 'is_admin': is_admin + } + + if is_admin: + view['invoice_email'] = o.invoice_email + + return view + + @app.route('/api/organization/', methods=['GET']) @api_login_required def get_organization(orgname): @@ -347,17 +367,6 @@ def get_organization(orgname): if permission.can(): user = current_user.db_user() - def org_view(o, teams): - admin_org = AdministerOrganizationPermission(orgname) - is_admin = admin_org.can() - return { - 'name': o.username, - 'email': o.email if is_admin else '', - 'gravatar': compute_hash(o.email), - 'teams': {t.name : team_view(orgname, t) for t in teams}, - 'is_admin': is_admin - } - try: org = model.get_organization(orgname) except model.InvalidOrganizationException: @@ -368,6 +377,28 @@ def get_organization(orgname): abort(403) + +@app.route('/api/organization/', methods=['PUT']) +@api_login_required +def change_organization_details(orgname): + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + abort(404) + + org_data = request.get_json(); + if 'invoice_email' in org_data: + logger.debug('Changing invoice_email for organization: %s', org.username) + model.change_invoice_email(org, org_data['invoice_email']) + + teams = model.get_teams_within_org(org) + return jsonify(org_view(org, teams)) + + abort(403) + + @app.route('/api/organization//members', methods=['GET']) @api_login_required def get_organization_members(orgname): diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py new file mode 100644 index 000000000..9675fe000 --- /dev/null +++ b/endpoints/webhooks.py @@ -0,0 +1,42 @@ +import logging +import requests +import stripe + +from flask import (abort, redirect, request, url_for, render_template, + make_response) +from flask.ext.login import login_user, UserMixin, login_required +from flask.ext.principal import identity_changed, Identity, AnonymousIdentity + +from data import model +from app import app, login_manager, mixpanel +from auth.permissions import QuayDeferredPermissionUser +from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan +from util.invoice import renderInvoiceToHtml +from util.email import send_invoice_email + +logger = logging.getLogger(__name__) + + +@app.route('/webhooks/stripe', methods=['POST']) +def stripe_webhook(): + request_data = request.get_json() + logger.debug('Stripe webhook call: %s' % request_data) + + event_type = request_data['type'] if 'type' in request_data else None + if event_type == 'charge.succeeded': + data = request_data['data'] if 'data' in request_data else {} + obj = data['object'] if 'object' in data else {} + invoice_id = obj['invoice'] if 'invoice' in obj else None + customer_id = obj['customer'] if 'customer' in obj else None + + if invoice_id and customer_id: + # Find the user associated with the customer ID. + user = model.get_user_or_org_by_customer_id(customer_id) + if user and user.invoice_email: + # Lookup the invoice. + invoice = stripe.Invoice.retrieve(invoice_id) + if invoice: + invoice_html = renderInvoiceToHtml(invoice, user) + send_invoice_email(user.email, invoice_html) + + return make_response('Okay') diff --git a/static/css/quay.css b/static/css/quay.css index 72d6f41d5..e466e43d6 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -6,6 +6,20 @@ visibility: hidden; } +.settings-option { + padding: 4px; + font-size: 18px; +} + +.settings-option label { + margin-left: 6px; +} + +.settings-option .settings-description { + font-size: 12px; + color: #aaa; +} + .organization-header-element { padding: 20px; margin-bottom: 20px; diff --git a/static/directives/billing-options.html b/static/directives/billing-options.html new file mode 100644 index 000000000..777a56519 --- /dev/null +++ b/static/directives/billing-options.html @@ -0,0 +1,17 @@ +
+
+
+ Billing Options + +
+
+
+ + +
+ If checked, a receipt email will be sent to {{ obj.email }} on every successful billing +
+
+
+
+
diff --git a/static/js/app.js b/static/js/app.js index 2a0d08236..c1d77bd77 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -621,6 +621,52 @@ quayApp.directive('roleGroup', function () { }); +quayApp.directive('billingOptions', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-options.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'organization': '=organization' + }, + controller: function($scope, $element, Restangular) { + $scope.invoice_email = false; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + $scope.obj = $scope.user ? $scope.user : $scope.organization; + $scope.invoice_email = $scope.obj.invoice_email; + }; + + var save = function() { + $scope.working = true; + var url = $scope.organization ? getRestUrl('organization', $scope.organization.name) : 'user/'; + var conductSave = Restangular.one(url); + conductSave.customPUT($scope.obj).then(function(resp) { + $scope.working = false; + }); + }; + + var checkSave = function() { + if (!$scope.obj) { return; } + if ($scope.obj.invoice_email != $scope.invoice_email) { + $scope.obj.invoice_email = $scope.invoice_email; + save(); + } + }; + + $scope.$watch('invoice_email', checkSave); + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); + + quayApp.directive('planManager', function () { var directiveDefinitionObject = { priority: 0, diff --git a/static/partials/org-admin.html b/static/partials/org-admin.html index 1ed36b0c5..7df7b3fd9 100644 --- a/static/partials/org-admin.html +++ b/static/partials/org-admin.html @@ -29,8 +29,10 @@
+
+
- + Loading billing history:
diff --git a/static/partials/user-admin.html b/static/partials/user-admin.html index 0cfb27e2c..db81cb629 100644 --- a/static/partials/user-admin.html +++ b/static/partials/user-admin.html @@ -28,6 +28,7 @@
@@ -57,6 +58,11 @@
+ + +
+
+
diff --git a/test/data/test.db b/test/data/test.db index 20ed3819d..e7e752706 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/email.py b/util/email.py index 2698ccbed..79cd2dd0f 100644 --- a/util/email.py +++ b/util/email.py @@ -39,3 +39,11 @@ def send_recovery_email(email, token): recipients=[email]) msg.html = RECOVERY_MESSAGE % (token, token) mail.send(msg) + + +def send_invoice_email(email, contents): + msg = Message('Quay.io payment received - Thank you!', + sender='support@quay.io', # Why do I need this? + recipients=[email]) + msg.html = contents + mail.send(msg) diff --git a/util/invoice.py b/util/invoice.py new file mode 100644 index 000000000..6cefa5099 --- /dev/null +++ b/util/invoice.py @@ -0,0 +1,36 @@ +from datetime import datetime +from jinja2 import Environment, FileSystemLoader + +jinja_options = { + "loader": FileSystemLoader('util'), +} + +env = Environment(**jinja_options) + +def renderInvoiceToHtml(invoice, user): + """ Renders a nice HTML display for the given invoice. """ + def get_price(price): + if not price: + return '$0' + + return '$' + '{.2f}'.format(price / 100) + + def get_range(line): + if line.period and line.period.start and line.period.end: + return ': ' + format_date(line.period.start) + ' - ' + format_date(line.period.end) + return '' + + def format_date(timestamp): + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + + data = { + 'user': user.username, + 'invoice': invoice, + 'invoice_date': format_date(invoice.date), + 'getPrice': get_price, + 'getRange': get_range + } + + template = env.get_template('invoice.tmpl') + rendered = template.render(data) + return rendered diff --git a/util/invoice.tmpl b/util/invoice.tmpl new file mode 100644 index 000000000..95cb5a23a --- /dev/null +++ b/util/invoice.tmpl @@ -0,0 +1,63 @@ + + + + + + + + +
+ Quay.io + +

Quay.io

+

+ DevTable, LLC
+ https://devtable.com
+ PO Box 48
+ New York, NY 10009 +

+
+

RECEIPT

+ + + +
Date:{{ invoice_date }}
Invoice #:{{ invoice.id }}
+
+ +
+ + + + + + + +{%- for line in invoice.lines.data -%} + + + + +{%- endfor -%} + + + + + + + +
DescriptionLine Total
{{ line.description or ('Plan Subscription' + getRange(line)) }}{{ getPrice(line.amount) }}
+ + + + + + +
Subtotal: {{ getPrice(invoice.subtotal) }}
Total: {{ getPrice(invoice.total) }}
Paid: {{ getPrice(invoice.total) if invoice.paid else 0 }}
Total Due:{{ getPrice(invoice.ending_balance) }}
+
+ +
+ We thank you for your continued business! +
+ + +