From 457b61964796911cb32efdfbe270dc08fcd8f13e Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 Nov 2013 14:42:31 -0500 Subject: [PATCH] Add receipt/invoice email support and option to Quay --- application.py | 1 + data/database.py | 1 + data/model.py | 11 +++ endpoints/api.py | 93 ++++++++++++++++--------- endpoints/webhooks.py | 42 +++++++++++ static/css/quay.css | 14 ++++ static/directives/billing-options.html | 17 +++++ static/js/app.js | 46 ++++++++++++ static/partials/org-admin.html | 4 +- static/partials/user-admin.html | 6 ++ test/data/test.db | Bin 96256 -> 286720 bytes util/email.py | 8 +++ util/invoice.py | 36 ++++++++++ util/invoice.tmpl | 63 +++++++++++++++++ 14 files changed, 310 insertions(+), 32 deletions(-) create mode 100644 endpoints/webhooks.py create mode 100644 static/directives/billing-options.html create mode 100644 util/invoice.py create mode 100644 util/invoice.tmpl 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 20ed3819d36d71d54cdf20cd477623d14002ea5f..e7e752706dc5df4d2cf2080d352b88f53a94a283 100644 GIT binary patch delta 6940 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 delta 5196 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 + + + + + + + +
+ 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! +
+ + +