From 10efa96009f420201c2b2c93f5d81fa0f420d349 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 28 Dec 2015 13:59:50 -0500 Subject: [PATCH] Add support for custom billing invoice email address Fixes #782 --- data/database.py | 1 + ...2cb66_add_invoice_email_address_to_user.py | 27 ++++++++++++++++++ data/model/user.py | 8 +++++- endpoints/api/organization.py | 13 ++++++++- endpoints/api/user.py | 11 ++++++- endpoints/webhooks.py | 2 +- static/css/directives/ui/billing-options.css | 15 ++++++++++ static/css/quay.css | 15 ---------- static/directives/billing-options.html | 4 +-- static/js/directives/ui/billing-options.js | 17 +++++++++++ test/data/test.db | Bin 1118208 -> 1122304 bytes tools/emailinvoice.py | 4 +-- 12 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 data/migrations/versions/471caec2cb66_add_invoice_email_address_to_user.py create mode 100644 static/css/directives/ui/billing-options.css diff --git a/data/database.py b/data/database.py index bc7367108..08bd54003 100644 --- a/data/database.py +++ b/data/database.py @@ -288,6 +288,7 @@ class User(BaseModel): last_invalid_login = DateTimeField(default=datetime.utcnow) removed_tag_expiration_s = IntegerField(default=1209600) # Two weeks enabled = BooleanField(default=True) + invoice_email_address = CharField(null=True, index=True) def delete_instance(self, recursive=False, delete_nullable=False): # If we are deleting a robot account, only execute the subset of queries necessary. diff --git a/data/migrations/versions/471caec2cb66_add_invoice_email_address_to_user.py b/data/migrations/versions/471caec2cb66_add_invoice_email_address_to_user.py new file mode 100644 index 000000000..173f23624 --- /dev/null +++ b/data/migrations/versions/471caec2cb66_add_invoice_email_address_to_user.py @@ -0,0 +1,27 @@ +"""Add invoice email address to user + +Revision ID: 471caec2cb66 +Revises: 88e0f440a2f +Create Date: 2015-12-28 13:57:17.761334 + +""" + +# revision identifiers, used by Alembic. +revision = '471caec2cb66' +down_revision = '88e0f440a2f' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('invoice_email_address', sa.String(length=255), nullable=True)) + op.create_index('user_invoice_email_address', 'user', ['invoice_email_address'], unique=False) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'invoice_email_address') + ### end Alembic commands ### diff --git a/data/model/user.py b/data/model/user.py index d05ea1693..85f08d220 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -125,7 +125,13 @@ def change_username(user_id, new_username): return user -def change_invoice_email(user, invoice_email): +def change_invoice_email_address(user, invoice_email_address): + # Note: We null out the address if it is an empty string. + user.invoice_email_address = invoice_email_address or None + user.save() + + +def change_send_invoice_email(user, invoice_email): user.invoice_email = invoice_email user.save() diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index 818851caf..db5e1a67f 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -44,6 +44,7 @@ def org_view(o, teams): if is_admin: view['invoice_email'] = o.invoice_email + view['invoice_email_address'] = o.invoice_email_address return view @@ -119,6 +120,10 @@ class Organization(ApiResource): 'type': 'boolean', 'description': 'Whether the organization desires to receive emails for invoices', }, + 'invoice_email_address': { + 'type': 'string', + 'description': 'The email address at which to receive invoices', + }, 'tag_expiration': { 'type': 'integer', 'maximum': 2592000, @@ -159,7 +164,13 @@ class Organization(ApiResource): org_data = request.get_json() if 'invoice_email' in org_data: logger.debug('Changing invoice_email for organization: %s', org.username) - model.user.change_invoice_email(org, org_data['invoice_email']) + model.user.change_send_invoice_email(org, org_data['invoice_email']) + + if ('invoice_email_address' in org_data and + org_data['invoice_email_address'] != org.invoice_email_address): + new_email = org_data['invoice_email_address'] + logger.debug('Changing invoice email address for organization: %s', org.username) + model.user.change_invoice_email_address(org, new_email) if 'email' in org_data and org_data['email'] != org.email: new_email = org_data['email'] diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 1801598f7..1319fd2d6 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -109,6 +109,7 @@ def user_view(user): 'email': user.email, 'logins': [login_view(login) for login in logins], 'invoice_email': user.invoice_email, + 'invoice_email_address': user.invoice_email_address, 'preferred_namespace': not (user.stripe_id is None), 'tag_expiration': user.removed_tag_expiration_s, }) @@ -195,6 +196,10 @@ class User(ApiResource): 'type': 'string', 'description': 'The user\'s username', }, + 'invoice_email_address': { + 'type': 'string', + 'description': 'Custom email address for receiving invoices', + } }, }, 'UserView': { @@ -283,12 +288,16 @@ class User(ApiResource): if 'invoice_email' in user_data: logger.debug('Changing invoice_email for user: %s', user.username) - model.user.change_invoice_email(user, user_data['invoice_email']) + model.user.change_send_invoice_email(user, user_data['invoice_email']) if 'tag_expiration' in user_data: logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration']) model.user.change_user_tag_expiration(user, user_data['tag_expiration']) + if ('invoice_email_address' in user_data and + user_data['invoice_email_address'] != user.invoice_email_address): + model.user.change_invoice_email_address(user, user_data['invoice_email_address']) + if 'email' in user_data and user_data['email'] != user.email: new_email = user_data['email'] if model.user.find_user_by_email(new_email): diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py index 1b3d23f23..cb13f9fd9 100644 --- a/endpoints/webhooks.py +++ b/endpoints/webhooks.py @@ -37,7 +37,7 @@ def stripe_webhook(): invoice = stripe.Invoice.retrieve(invoice_id) if invoice: invoice_html = renderInvoiceToHtml(invoice, user) - send_invoice_email(user.email, invoice_html) + send_invoice_email(user.invoice_email_address or user.email, invoice_html) elif event_type.startswith('customer.subscription.'): cust_email = user.email if user is not None else 'unknown@domain.com' diff --git a/static/css/directives/ui/billing-options.css b/static/css/directives/ui/billing-options.css new file mode 100644 index 000000000..3e0f4ee06 --- /dev/null +++ b/static/css/directives/ui/billing-options.css @@ -0,0 +1,15 @@ +.billing-options .settings-option { + padding: 4px; + font-size: 18px; + margin-bottom: 10px; +} + +.billing-options .settings-option label { + margin-left: 6px; +} + +.billing-options .settings-option .settings-description { + font-size: 16px; + color: #888; + padding-left: 26px; +} \ No newline at end of file diff --git a/static/css/quay.css b/static/css/quay.css index b58bac9fd..904c45aeb 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -506,21 +506,6 @@ i.toggle-icon:hover { vertical-align: middle; } -.settings-option { - padding: 4px; - font-size: 18px; - margin-bottom: 10px; -} - -.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 index aa9089f03..9428e4258 100644 --- a/static/directives/billing-options.html +++ b/static/directives/billing-options.html @@ -31,7 +31,7 @@
- Billing Options + Billing Receipts
@@ -39,7 +39,7 @@
- If checked, a receipt email will be sent to {{ obj.email }} on every successful charge + If checked, a receipt email will be sent to {{ obj.invoice_email_address || obj.email }} on every successful charge
diff --git a/static/js/directives/ui/billing-options.js b/static/js/directives/ui/billing-options.js index 4400d527b..d082310c1 100644 --- a/static/js/directives/ui/billing-options.js +++ b/static/js/directives/ui/billing-options.js @@ -34,6 +34,23 @@ angular.module('quay').directive('billingOptions', function () { return difference < (60 * 60 * 24 * 60 * 1000 /* 60 days */); }; + $scope.changeInvoiceEmailAddress = function() { + bootbox.prompt('Enter the email address for receiving receipts', function(email) { + // Copied from Angular. + var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i; + if (!email || !EMAIL_REGEXP.test(email)) { + return; + } + + $scope.obj['invoice_email_address'] = email; + + var errorHandler = ApiService.errorDisplay('Could not change user details'); + ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { + $scope.working = false; + }, errorHandler); + }); + }; + $scope.changeCard = function() { var previousCard = $scope.currentCard; $scope.changingCard = true; diff --git a/test/data/test.db b/test/data/test.db index 89268f9eba27a3e9ee0ee0ab74b3fe316cf096db..1738e57c4d5be15de4fafde3eed5882ff3cf3e7d 100644 GIT binary patch delta 54631 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