Add receipt/invoice email support and option to Quay
This commit is contained in:
parent
318dc79de3
commit
457b619647
14 changed files with 310 additions and 32 deletions
|
@ -11,6 +11,7 @@ import endpoints.api
|
|||
import endpoints.web
|
||||
import endpoints.tags
|
||||
import endpoints.registry
|
||||
import endpoints.webhooks
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/<orgname>', 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/<orgname>', 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/<orgname>/members', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_organization_members(orgname):
|
||||
|
|
42
endpoints/webhooks.py
Normal file
42
endpoints/webhooks.py
Normal file
|
@ -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')
|
|
@ -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;
|
||||
|
|
17
static/directives/billing-options.html
Normal file
17
static/directives/billing-options.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<div class="billing-options-element">
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
Billing Options
|
||||
<i class="fa fa-spinner fa-spin" ng-show="working"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="settings-option">
|
||||
<input id="invoiceEmail" type="checkbox" ng-model="invoice_email">
|
||||
<label for="invoiceEmail">Send Receipt Emails</label>
|
||||
<div class="settings-description">
|
||||
If checked, a receipt email will be sent to {{ obj.email }} on every successful billing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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,
|
||||
|
|
|
@ -29,8 +29,10 @@
|
|||
|
||||
<!-- Billing tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div class="billing-options" organization="organization"></div>
|
||||
|
||||
<div ng-show="invoiceLoading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
Loading billing history: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i>
|
||||
</div>
|
||||
|
||||
<div ng-show="!invoiceLoading && !invoices">
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
<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="#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>
|
||||
|
@ -57,6 +58,11 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing options tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div class="billing-options" user="user"></div>
|
||||
</div>
|
||||
|
||||
<!-- Convert to organization tab -->
|
||||
<div id="migrate" class="tab-pane">
|
||||
|
|
Binary file not shown.
|
@ -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)
|
||||
|
|
36
util/invoice.py
Normal file
36
util/invoice.py
Normal file
|
@ -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
|
63
util/invoice.tmpl
Normal file
63
util/invoice.tmpl
Normal file
|
@ -0,0 +1,63 @@
|
|||
<html>
|
||||
<body>
|
||||
<table width="100%" style="max-width: 640px">
|
||||
<tr>
|
||||
<td valign="center" style="padding: 10px;">
|
||||
<img src="https://quay.io/static/img/quay-logo.png" alt="Quay.io" style="width: 100px;">
|
||||
</td>
|
||||
<td valign="center">
|
||||
<h3>Quay.io</h3>
|
||||
<p style="font-size: 12px; -webkit-text-adjust: none">
|
||||
DevTable, LLC<br>
|
||||
https://devtable.com<br>
|
||||
PO Box 48<br>
|
||||
New York, NY 10009
|
||||
</p>
|
||||
</td>
|
||||
<td align="right" width="100%">
|
||||
<h1 style="color: #ddd;">RECEIPT</h1>
|
||||
<table>
|
||||
<tr><td>Date:</td><td>{{ invoice_date }}</td></tr>
|
||||
<tr><td>Invoice #:</td><td style="font-size: 10px">{{ invoice.id }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<table width="100%" style="max-width: 640px">
|
||||
<thead>
|
||||
<th style="padding: 4px; background: #eee; text-align: center; font-weight: bold">Description</th>
|
||||
<th style="padding: 4px; background: #eee; text-align: center; font-weight: bold">Line Total</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for line in invoice.lines.data -%}
|
||||
<tr>
|
||||
<td width="100%" style="padding: 4px;">{{ line.description or ('Plan Subscription' + getRange(line)) }}</td>
|
||||
<td style="padding: 4px; min-width: 150px;">{{ getPrice(line.amount) }}</td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
|
||||
|
||||
<tr>
|
||||
<td></td>
|
||||
<td valign="right">
|
||||
<table>
|
||||
<tr><td><b>Subtotal: </b></td><td>{{ getPrice(invoice.subtotal) }}</td></tr>
|
||||
<tr><td><b>Total: </b></td><td>{{ getPrice(invoice.total) }}</td></tr>
|
||||
<tr><td><b>Paid: </b></td><td>{{ getPrice(invoice.total) if invoice.paid else 0 }}</td></tr>
|
||||
<tr><td><b>Total Due:</b></td>
|
||||
<td>{{ getPrice(invoice.ending_balance) }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin: 6px; padding: 6px; width: 100%; max-width: 640px; border-top: 2px solid #eee; text-align: center; font-size: 14px; -webkit-text-adjust: none; font-weight: bold;">
|
||||
We thank you for your continued business!
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
Reference in a new issue