From e7fa560787d3d0f56d198078f328b1602ae7a1f4 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 12 Jun 2015 12:32:41 -0400 Subject: [PATCH] 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 --- data/model/legacy.py | 1 - endpoints/api/billing.py | 200 ++++++++++++++++++ static/css/directives/ui/billing-invoices.css | 19 ++ static/directives/billing-invoices.html | 43 ++++ static/js/directives/ui/billing-invoices.js | 50 +++++ test/data/test.db | Bin 778240 -> 778240 bytes test/test_api_security.py | 104 ++++++++- util/invoice.py | 5 +- util/invoice.tmpl | 12 +- 9 files changed, 426 insertions(+), 8 deletions(-) diff --git a/data/model/legacy.py b/data/model/legacy.py index 464131f55..f34ef7cae 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -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) - diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 36dc96102..0342c9ea0 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -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/') +@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//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//invoice/field/') +@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) diff --git a/static/css/directives/ui/billing-invoices.css b/static/css/directives/ui/billing-invoices.css index 10011237c..299a5e937 100644 --- a/static/css/directives/ui/billing-invoices.css +++ b/static/css/directives/ui/billing-invoices.css @@ -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; } \ No newline at end of file diff --git a/static/directives/billing-invoices.html b/static/directives/billing-invoices.html index 802abc358..83200cb64 100644 --- a/static/directives/billing-invoices.html +++ b/static/directives/billing-invoices.html @@ -9,6 +9,30 @@
+ + @@ -39,4 +63,23 @@
Billing Date/Time
+ +
+
+
+ + +
+
+ + +
+
+
+ + diff --git a/static/js/directives/ui/billing-invoices.js b/static/js/directives/ui/billing-invoices.js index ad6696621..1ac3ff562 100644 --- a/static/js/directives/ui/billing-invoices.js +++ b/static/js/directives/ui/billing-invoices.js @@ -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')); + }; } }; diff --git a/test/data/test.db b/test/data/test.db index ad88374b682db7930da395ccac95d669a7efc776..316bc529c3fd8f298c2bb684cbbb2c2e2c9846fd 100644 GIT binary patch delta 14380 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 VlV_8y)^Jr%$O2)J(2C38 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 M?NNbT9+~-n0H(|QuK)l5 diff --git a/test/test_api_security.py b/test/test_api_security.py index 0b9b0f09d..078faecae 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -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() diff --git a/util/invoice.py b/util/invoice.py index 1c0650e18..b6a81cca5 100644 --- a/util/invoice.py +++ b/util/invoice.py @@ -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') diff --git a/util/invoice.tmpl b/util/invoice.tmpl index fac657ef5..68228886f 100644 --- a/util/invoice.tmpl +++ b/util/invoice.tmpl @@ -19,6 +19,12 @@ + {% for custom_field in custom_fields %} + + + + + {% endfor %}
Date:{{ invoice_date }}
Invoice #:{{ invoice.id }}
*{{ custom_field['title'] }}:{{ custom_field['value'] }}
@@ -38,8 +44,8 @@ {{ getPrice(line.amount) }} {%- endfor -%} - - + + @@ -54,7 +60,7 @@ - +
We thank you for your continued business!