Add support for custom fields in billing invoices

Customers (especially in Europe) need the ability to add Tax IDs, VAT IDs, and other custom fields to their invoices.

Fixes #106
This commit is contained in:
Joseph Schorr 2015-06-12 12:32:41 -04:00
parent 683d5080d8
commit e7fa560787
9 changed files with 426 additions and 8 deletions

View file

@ -2928,4 +2928,3 @@ def revert_tag(repository, tag_name, docker_image_id):
return create_or_update_tag(repository.namespace_user.username, repository.name,
tag_name, docker_image_id, reversion=True)

View file

@ -13,6 +13,8 @@ from data import model
from data.billing import PLANS
import features
import uuid
import json
def carderror_response(e):
return {'carderror': e.message}, 402
@ -96,6 +98,48 @@ def get_invoices(customer_id):
}
def get_invoice_fields(user):
try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.APIConnectionError:
abort(503, message='Cannot contact Stripe')
if not 'metadata' in cus:
cus.metadata = {}
return json.loads(cus.metadata.get('invoice_fields') or '[]'), cus
def create_billing_invoice_field(user, title, value):
new_field = {
'uuid': str(uuid.uuid4()).split('-')[0],
'title': title,
'value': value
}
invoice_fields, cus = get_invoice_fields(user)
invoice_fields.append(new_field)
if not 'metadata' in cus:
cus.metadata = {}
cus.metadata['invoice_fields'] = json.dumps(invoice_fields)
cus.save()
return new_field
def delete_billing_invoice_field(user, field_uuid):
invoice_fields, cus = get_invoice_fields(user)
invoice_fields = [field for field in invoice_fields if not field['uuid'] == field_uuid]
if not 'metadata' in cus:
cus.metadata = {}
cus.metadata['invoice_fields'] = json.dumps(invoice_fields)
cus.save()
return True
@resource('/v1/plans/')
@show_if(features.BILLING)
class ListPlans(ApiResource):
@ -367,3 +411,159 @@ class OrgnaizationInvoiceList(ApiResource):
return get_invoices(organization.stripe_id)
raise Unauthorized()
@resource('/v1/user/invoice/fields')
@internal_only
@show_if(features.BILLING)
class UserInvoiceFieldList(ApiResource):
""" Resource for listing and creating a user's custom invoice fields. """
schemas = {
'InvoiceField': {
'id': 'InvoiceField',
'type': 'object',
'description': 'Description of an invoice field',
'required': [
'title', 'value'
],
'properties': {
'title': {
'type': 'string',
'description': 'The title of the field being added',
},
'value': {
'type': 'string',
'description': 'The value of the field being added',
},
},
},
}
@require_user_admin
@nickname('listUserInvoiceFields')
def get(self):
""" List the invoice fields for the current user. """
user = get_authenticated_user()
if not user.stripe_id:
raise NotFound()
return {'fields': get_invoice_fields(user)[0]}
@require_user_admin
@nickname('createUserInvoiceField')
@validate_json_request('InvoiceField')
def post(self):
""" Creates a new invoice field. """
user = get_authenticated_user()
if not user.stripe_id:
raise NotFound()
data = request.get_json()
created_field = create_billing_invoice_field(user, data['title'], data['value'])
return created_field
@resource('/v1/user/invoice/field/<field_uuid>')
@internal_only
@show_if(features.BILLING)
class UserInvoiceField(ApiResource):
""" Resource for deleting a user's custom invoice fields. """
@require_user_admin
@nickname('deleteUserInvoiceField')
def delete(self, field_uuid):
""" Deletes the invoice field for the current user. """
user = get_authenticated_user()
if not user.stripe_id:
raise NotFound()
result = delete_billing_invoice_field(user, field_uuid)
if not result:
abort(404)
return 'Okay', 201
@resource('/v1/organization/<orgname>/invoice/fields')
@path_param('orgname', 'The name of the organization')
@related_user_resource(UserInvoiceFieldList)
@internal_only
@show_if(features.BILLING)
class OrganizationInvoiceFieldList(ApiResource):
""" Resource for listing and creating an organization's custom invoice fields. """
schemas = {
'InvoiceField': {
'id': 'InvoiceField',
'type': 'object',
'description': 'Description of an invoice field',
'required': [
'title', 'value'
],
'properties': {
'title': {
'type': 'string',
'description': 'The title of the field being added',
},
'value': {
'type': 'string',
'description': 'The value of the field being added',
},
},
},
}
@require_scope(scopes.ORG_ADMIN)
@nickname('listOrgInvoiceFields')
def get(self, orgname):
""" List the invoice fields for the organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
return {'fields': get_invoice_fields(organization)[0]}
abort(403)
@require_scope(scopes.ORG_ADMIN)
@nickname('createOrgInvoiceField')
@validate_json_request('InvoiceField')
def post(self, orgname):
""" Creates a new invoice field. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
data = request.get_json()
created_field = create_billing_invoice_field(organization, data['title'], data['value'])
return created_field
abort(403)
@resource('/v1/organization/<orgname>/invoice/field/<field_uuid>')
@path_param('orgname', 'The name of the organization')
@related_user_resource(UserInvoiceField)
@internal_only
@show_if(features.BILLING)
class OrganizationInvoiceField(ApiResource):
""" Resource for deleting an organization's custom invoice fields. """
@require_scope(scopes.ORG_ADMIN)
@nickname('deleteOrgInvoiceField')
def delete(self, orgname, field_uuid):
""" Deletes the invoice field for the current user. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
result = delete_billing_invoice_field(organization, field_uuid)
if not result:
abort(404)
return 'Okay', 201
abort(403)

View file

@ -1,3 +1,6 @@
.billing-invoices-element .fields-menu {
float: right;
}
.billing-invoices-element .invoice-title {
padding: 6px;
@ -22,4 +25,20 @@
.billing-invoices-element .fa-download {
color: #aaa;
}
.billing-invoices-element .fa-trash-o {
float: right;
margin-top: -3px;
margin-right: -14px !important;
font-size: 14px;
padding: 2px;
padding-left: 6px;
padding-right: 6px;
border-radius: 4px;
}
.billing-invoices-element .invoice-field {
padding-top: 6px;
padding-bottom: 6px;
}

View file

@ -9,6 +9,30 @@
</div>
<div ng-show="!loading && invoices.length">
<div class="dropdown fields-menu" data-title="Custom Invoice Fields" bs-tooltip>
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<i class="fa fa-bars"></i>
<span class="caret"></span>
</button>
<ul class="dropdown-menu pull-right" role="menu">
<li role="presentation" ng-repeat="invoiceField in invoiceFields">
<a class="invoice-field" role="menuitem" tabindex="-1" href="javascript:void(0)" >
{{ invoiceField.title }}: {{ invoiceField.value }}
<i class="fa fa-trash-o btn btn-danger" ng-click="askDeleteField(invoiceField)"></i>
</a>
</li>
<li role="presentation" class="disabled" ng-if="!invoiceFields.length">
<a role="menuitem" tabindex="-1" href="javascript:void(0)">No Custom Fields Defined</a>
</li>
<li role="presentation" class="divider"></li>
<li role="presentation">
<a role="menuitem" tabindex="-1" href="javascript:void(0)" ng-click="showCreateField()">
<i class="fa fa-plus"></i>Add Custom Invoice Field
</a>
</li>
</ul>
</div>
<table class="co-table">
<thead>
<td>Billing Date/Time</td>
@ -39,4 +63,23 @@
</table>
</div>
<!-- Delete Tag Confirm -->
<div class="cor-confirm-dialog"
dialog-context="createFieldInfo"
dialog-action="createCustomField(info.title, info.value, callback)"
dialog-title="Create Custom Field"
dialog-action-title="Create Field">
<form>
<div class="form-group">
<label for="titleInput">Enter Field Title</label>
<input id="titleInput" type="text" class="form-control"ng-model="createFieldInfo.title" placeholder="Field Title">
</div>
<div class="form-group">
<label for="valueInput">Enter Field Value</label>
<input id="valueInput" type="text" class="form-control" ng-model="createFieldInfo.value" placeholder="Field Value">
</div>
</form>
</div>
</div>
</div>

View file

@ -15,6 +15,8 @@ angular.module('quay').directive('billingInvoices', function () {
},
controller: function($scope, $element, $sce, ApiService) {
$scope.loading = false;
$scope.showCreateField = null;
$scope.invoiceFields = [];
var update = function() {
var hasValidUser = !!$scope.user;
@ -34,11 +36,59 @@ angular.module('quay').directive('billingInvoices', function () {
$scope.invoices = [];
$scope.loading = false;
});
ApiService.listInvoiceFields($scope.organization).then(function(resp) {
$scope.invoiceFields = resp.fields || [];
}, function() {
$scope.invoiceFields = [];
});
};
$scope.$watch('organization', update);
$scope.$watch('user', update);
$scope.$watch('makevisible', update);
$scope.showCreateField = function() {
$scope.createFieldInfo = {
'title': '',
'value': ''
};
};
$scope.askDeleteField = function(field) {
bootbox.confirm('Are you sure you want to delete field ' + field.title + '?', function(r) {
if (r) {
var params = {
'field_uuid': field.uuid
};
ApiService.deleteInvoiceField($scope.organization, null, params).then(function(resp) {
$scope.invoiceFields = $.grep($scope.invoiceFields, function(current) {
return current.uuid != field.uuid
});
}, ApiService.errorDisplay('Could not delete custom field'));
}
});
};
$scope.createCustomField = function(title, value, callback) {
var data = {
'title': title,
'value': value
};
if (!title || !value) {
callback(false);
bootbox.alert('Missing title or value');
return;
}
ApiService.createInvoiceField($scope.organization, data).then(function(resp) {
$scope.invoiceFields.push(resp);
callback(true);
}, ApiService.errorDisplay('Could not create custom field'));
};
}
};

Binary file not shown.

View file

@ -19,7 +19,6 @@ from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, Reposi
from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
RegenerateOrgRobot, RegenerateUserRobot, UserRobotPermissions,
OrgRobotPermissions)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
@ -33,7 +32,9 @@ from endpoints.api.repotoken import RepositoryToken, RepositoryTokenList
from endpoints.api.prototype import PermissionPrototype, PermissionPrototypeList
from endpoints.api.logs import UserLogs, OrgLogs, RepositoryLogs
from endpoints.api.billing import (UserInvoiceList, UserCard, UserPlan, ListPlans,
OrgnaizationInvoiceList, OrganizationCard, OrganizationPlan)
OrgnaizationInvoiceList, OrganizationCard, OrganizationPlan,
UserInvoiceFieldList, UserInvoiceField,
OrganizationInvoiceFieldList, OrganizationInvoiceField)
from endpoints.api.discovery import DiscoveryResource
from endpoints.api.organization import (OrganizationList, OrganizationMember,
OrgPrivateRepositories, OrgnaizationMemberList,
@ -43,7 +44,6 @@ from endpoints.api.organization import (OrganizationList, OrganizationMember,
from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository
from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPermission,
RepositoryTeamPermissionList, RepositoryUserPermissionList)
from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement,
SuperUserSendRecoveryEmail, UsageInformation,
SuperUserOrganizationManagement, SuperUserOrganizationList)
@ -4058,6 +4058,104 @@ class TestSuperUserManagement(ApiTestCase):
self._run_test('DELETE', 204, 'devtable', None)
class TestUserInvoiceFieldList(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(UserInvoiceFieldList)
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 404, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 404, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 401, None, None)
def test_post_freshuser(self):
self._run_test('POST', 404, 'freshuser', dict(title='foo', value='bar'))
def test_post_reader(self):
self._run_test('POST', 404, 'reader', dict(title='foo', value='bar'))
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', dict(title='foo', value='bar'))
class TestUserInvoiceField(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(UserInvoiceField, field_uuid='1234')
def test_get_anonymous(self):
self._run_test('DELETE', 401, None, None)
def test_get_freshuser(self):
self._run_test('DELETE', 404, 'freshuser', None)
def test_get_reader(self):
self._run_test('DELETE', 404, 'reader', None)
def test_get_devtable(self):
self._run_test('DELETE', 201, 'devtable', None)
class TestOrganizationInvoiceFieldList(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(OrganizationInvoiceFieldList, orgname='buynlarge')
def test_get_anonymous(self):
self._run_test('GET', 403, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 403, None, dict(title='foo', value='bar'))
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', dict(title='foo', value='bar'))
def test_post_reader(self):
self._run_test('POST', 403, 'reader', dict(title='foo', value='bar'))
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', dict(title='foo', value='bar'))
class TestOrganizationInvoiceField(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(OrganizationInvoiceField, orgname='buynlarge', field_uuid='1234')
def test_get_anonymous(self):
self._run_test('DELETE', 403, None, None)
def test_get_freshuser(self):
self._run_test('DELETE', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('DELETE', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('DELETE', 201, 'devtable', None)
if __name__ == '__main__':
unittest.main()

View file

@ -25,6 +25,8 @@ def renderInvoiceToPdf(invoice, user):
def renderInvoiceToHtml(invoice, user):
""" Renders a nice HTML display for the given invoice. """
from endpoints.api.billing import get_invoice_fields
def get_price(price):
if not price:
return '$0'
@ -44,7 +46,8 @@ def renderInvoiceToHtml(invoice, user):
'invoice': invoice,
'invoice_date': format_date(invoice.date),
'getPrice': get_price,
'getRange': get_range
'getRange': get_range,
'custom_fields': get_invoice_fields(user)[0],
}
template = env.get_template('invoice.tmpl')

View file

@ -19,6 +19,12 @@
<table>
<tr><td>Date:</td><td>{{ invoice_date }}</td></tr>
<tr><td>Invoice #:</td><td style="font-size: 10px">{{ invoice.id }}</td></tr>
{% for custom_field in custom_fields %}
<tr>
<td>*{{ custom_field['title'] }}:</td>
<td style="font-size: 10px">{{ custom_field['value'] }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
@ -38,8 +44,8 @@
<td style="padding: 4px; min-width: 150px;">{{ getPrice(line.amount) }}</td>
</tr>
{%- endfor -%}
<tr>
<td></td>
<td valign="right">
@ -54,7 +60,7 @@
</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>