diff --git a/data/billing.py b/data/billing.py index 0384a98cc..d524934a4 100644 --- a/data/billing.py +++ b/data/billing.py @@ -367,6 +367,9 @@ class FakeStripe(object): if cus_obj._subscription and cus_obj._subscription.id == sub_id: cus_obj._subscription = None + def default_payment_method(self, payment_method): + return "pm_somestripepaymentmethodid" + class Customer(AttrDict): FAKE_PLAN = AttrDict( { @@ -427,6 +430,9 @@ class FakeStripe(object): def plan(self, plan_name): self["new_plan"] = plan_name + def refresh(self): + return self + def save(self): if self.get("new_card", None) is not None: raise stripe.error.CardError( @@ -514,6 +520,26 @@ class FakeStripe(object): } ) + class PaymentMethod(AttrDict): + FAKE_PAYMENT_METHOD = AttrDict( + { + "id": "card123", + "type": "Visa", + "card": AttrDict( + { + "last4": "4242", + "exp_month": 5, + "exp_year": 2016, + } + ), + "billing_details": AttrDict({"name": "Joe User"}), + } + ) + + @classmethod + def retrieve(cls, payment_method_id): + return cls.FAKE_PAYMENT_METHOD + Subscription = FakeSubscription diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py index 15388346a..5c9eecebd 100644 --- a/endpoints/api/billing.py +++ b/endpoints/api/billing.py @@ -20,13 +20,21 @@ from endpoints.api import ( require_scope, abort, ) -from endpoints.exception import Unauthorized, NotFound -from endpoints.api.subscribe import subscribe, subscription_view + +from endpoints.exception import Unauthorized, NotFound, InvalidRequest +from endpoints.api.subscribe import ( + get_price, + change_subscription, + subscription_view, + connection_response, + check_repository_usage, +) from auth.permissions import AdministerOrganizationPermission from auth.auth_context import get_authenticated_user from auth import scopes from data import model from data.billing import PLANS, get_plan +from util.request import get_request_ip import features import uuid @@ -85,18 +93,15 @@ def get_card(user): except stripe.error.APIConnectionError as e: abort(503, message="Cannot contact Stripe") - if cus and cus.default_card: + if cus and cus.subscription: # Find the default card. - default_card = None - for card in cus.cards.data: - if card.id == cus.default_card: - default_card = card - break + payment_method = billing.PaymentMethod.retrieve(cus.subscription.default_payment_method) - if default_card: + if payment_method.card: + default_card = payment_method.card card_info = { - "owner": default_card.name, - "type": default_card.type, + "owner": payment_method.billing_details.name, + "type": payment_method.type, "last4": default_card.last4, "exp_month": default_card.exp_month, "exp_year": default_card.exp_year, @@ -105,27 +110,6 @@ def get_card(user): return {"card": card_info} -def set_card(user, token): - if user.stripe_id: - try: - cus = billing.Customer.retrieve(user.stripe_id) - except stripe.error.APIConnectionError as e: - abort(503, message="Cannot contact Stripe") - - if cus: - try: - cus.card = token - cus.save() - except stripe.error.CardError as exc: - return carderror_response(exc) - except stripe.error.InvalidRequestError as exc: - return carderror_response(exc) - except stripe.error.APIConnectionError as e: - return carderror_response(e) - - return get_card(user) - - def get_invoices(customer_id): def invoice_view(i): return { @@ -218,13 +202,15 @@ class UserCard(ApiResource): "id": "UserCard", "type": "object", "description": "Description of a user card", - "required": [ - "token", - ], + "required": ["success_url", "cancel_url"], "properties": { - "token": { + "success_url": { "type": "string", - "description": "Stripe token that is generated by stripe checkout.js", + "description": "Redirect url after successful checkout", + }, + "cancel_url": { + "type": "string", + "description": "Redirect url after cancelled checkout", }, }, }, @@ -247,10 +233,46 @@ class UserCard(ApiResource): Update the user's credit card. """ user = get_authenticated_user() - token = request.get_json()["token"] - response = set_card(user, token) - log_action("account_change_cc", user.username) - return response + assert user.stripe_id + + request_data = request.get_json() + success_url = request_data["success_url"] + cancel_url = request_data["cancel_url"] + + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.error.APIConnectionError as e: + abort(503, message="Cannot contact Stripe") + + if not cus: + raise InvalidRequest("Invalid Stripe customer") + + if not cus.subscription: + raise InvalidRequest("Invalid Stripe subscription") + + try: + checkout_session = stripe.checkout.Session.create( + payment_method_types=["card"], + mode="setup", + customer=user.stripe_id, + setup_intent_data={ + "metadata": { + "kind": "account_change_cc", + "namespace": user.username, + "performer": user.username, + "ip": get_request_ip(), + "subscription_id": cus.subscription.id, + }, + }, + success_url=success_url, + cancel_url=cancel_url, + ) + + return checkout_session + except stripe.error.APIConnectionError as se: + abort(503, message="Cannot contact Stripe: %s" % se) + except Exception as e: + abort(500, message=str(e)) @resource("/v1/organization//card") @@ -267,14 +289,16 @@ class OrganizationCard(ApiResource): "OrgCard": { "id": "OrgCard", "type": "object", - "description": "Description of a user card", - "required": [ - "token", - ], + "description": "Description of an Organization card", + "required": ["success_url", "cancel_url"], "properties": { - "token": { + "success_url": { "type": "string", - "description": "Stripe token that is generated by stripe checkout.js", + "description": "Redirect url after successful checkout", + }, + "cancel_url": { + "type": "string", + "description": "Redirect url after cancelled checkout", }, }, }, @@ -302,10 +326,46 @@ class OrganizationCard(ApiResource): permission = AdministerOrganizationPermission(orgname) if permission.can(): organization = model.organization.get_organization(orgname) - token = request.get_json()["token"] - response = set_card(organization, token) - log_action("account_change_cc", orgname) - return response + assert organization.stripe_id + + request_data = request.get_json() + success_url = request_data["success_url"] + cancel_url = request_data["cancel_url"] + + try: + cus = billing.Customer.retrieve(organization.stripe_id) + except stripe.error.APIConnectionError as e: + abort(503, message="Cannot contact Stripe") + + if not cus: + raise InvalidRequest("Invalid Stripe customer") + + if not cus.subscription: + raise InvalidRequest("Invalid Stripe subscription") + + try: + checkout_session = stripe.checkout.Session.create( + payment_method_types=["card"], + mode="setup", + customer=organization.stripe_id, + setup_intent_data={ + "metadata": { + "kind": "account_change_cc", + "namespace": organization.username, + "performer": get_authenticated_user().username, + "ip": get_request_ip(), + "subscription_id": cus.subscription.id, + }, + }, + success_url=success_url, + cancel_url=cancel_url, + ) + + return checkout_session + except stripe.error.APIConnectionError as se: + abort(503, message="Cannot contact Stripe: %s" % se) + except Exception as e: + abort(500, message=str(e)) raise Unauthorized() @@ -323,34 +383,101 @@ class UserPlan(ApiResource): "id": "UserSubscription", "type": "object", "description": "Description of a user card", - "required": [ - "plan", - ], + "required": ["plan"], "properties": { - "token": { - "type": "string", - "description": "Stripe token that is generated by stripe checkout.js", - }, "plan": { "type": "string", "description": "Plan name to which the user wants to subscribe", }, + "success_url": { + "type": "string", + "description": "Redirect url after successful checkout", + }, + "cancel_url": { + "type": "string", + "description": "Redirect url after cancelled checkout", + }, }, }, } + @require_user_admin() + @nickname("createUserSubscription") + @validate_json_request("UserSubscription") + def post(self): + """ + Create the user's subscription. Returns a Stripe checkout session. + """ + request_data = request.get_json() + plan = request_data["plan"] + success_url = request_data.get("success_url") + cancel_url = request_data.get("cancel_url") + + if not success_url or not cancel_url: + raise InvalidRequest() + + user = get_authenticated_user() + if not user.stripe_id: + try: + cus = billing.Customer.create( + email=user.email, + ) + user.stripe_id = cus.id + user.save() + except stripe.error.APIConnectionError as e: + return connection_response(e) + + try: + price = get_price(plan, False) + if not price: + abort(404, message="Plan not found") + + checkout_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price["stripeId"], + "quantity": 1, + }, + ], + customer=user.stripe_id, + subscription_data={ + "metadata": { + "kind": "account_change_plan", + "namespace": user.username, + "performer": user.username, + "ip": get_request_ip(), + "plan": price["stripeId"], + } + }, + mode="subscription", + success_url=success_url, + cancel_url=cancel_url, + ) + return checkout_session + except stripe.error.APIConnectionError as e: + abort(503, message="Cannot contact Stripe") + except Exception as e: + abort(500, message=str(e)) + @require_user_admin() @nickname("updateUserSubscription") @validate_json_request("UserSubscription") def put(self): """ - Create or update the user's subscription. + Update the user's existing subscription. """ request_data = request.get_json() plan = request_data["plan"] - token = request_data["token"] if "token" in request_data else None + user = get_authenticated_user() - return subscribe(user, plan, token, False) # Business features not required + if not user.stripe_id: + raise InvalidRequest() + + price = get_price(plan, False) + if not price: + abort(404, message="Plan not found") + + return change_subscription(user, price) @require_user_admin() @nickname("getUserSubscription") @@ -394,36 +521,107 @@ class OrganizationPlan(ApiResource): "id": "OrgSubscription", "type": "object", "description": "Description of a user card", - "required": [ - "plan", - ], + "required": ["plan"], "properties": { - "token": { - "type": "string", - "description": "Stripe token that is generated by stripe checkout.js", - }, "plan": { "type": "string", "description": "Plan name to which the user wants to subscribe", }, + "success_url": { + "type": "string", + "description": "Redirect url after successful checkout", + }, + "cancel_url": { + "type": "string", + "description": "Redirect url after cancelled checkout", + }, }, }, } @require_scope(scopes.ORG_ADMIN) - @nickname("updateOrgSubscription") + @nickname("createOrgSubscription") @validate_json_request("OrgSubscription") - def put(self, orgname): + def post(self, orgname): """ - Create or update the org's subscription. + Create the org's subscription. Returns a Stripe checkout session. """ permission = AdministerOrganizationPermission(orgname) if permission.can(): request_data = request.get_json() plan = request_data["plan"] - token = request_data["token"] if "token" in request_data else None + success_url = request_data.get("success_url") + cancel_url = request_data.get("cancel_url") + + if not success_url or not cancel_url: + raise InvalidRequest() + organization = model.organization.get_organization(orgname) - return subscribe(organization, plan, token, True) # Business plan required + if not organization.stripe_id: + try: + cus = billing.Customer.create( + email=organization.email, + ) + organization.stripe_id = cus.id + organization.save() + except stripe.error.APIConnectionError as e: + return connection_response(e) + + try: + price = get_price(plan, True) + if not price: + abort(404, message="Plan not found") + + checkout_session = stripe.checkout.Session.create( + line_items=[ + { + "price": price["stripeId"], + "quantity": 1, + }, + ], + customer=organization.stripe_id, + subscription_data={ + "metadata": { + "kind": "account_change_plan", + "namespace": organization.username, + "performer": get_authenticated_user().username, + "ip": get_request_ip(), + "plan": price["stripeId"], + }, + }, + mode="subscription", + success_url=success_url, + cancel_url=cancel_url, + ) + return checkout_session + except stripe.error.APIConnectionError as e: + abort(503, message="Cannot contact Stripe") + except Exception as e: + abort(500, message=str(e)) + + raise Unauthorized() + + @require_scope(scopes.ORG_ADMIN) + @nickname("updateOrgSubscription") + @validate_json_request("OrgSubscription") + def put(self, orgname): + """ + Update the org's subscription. + """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + request_data = request.get_json() + plan = request_data["plan"] + + organization = model.organization.get_organization(orgname) + if not organization.stripe_id: + raise InvalidRequest() + + price = get_price(plan, True) + if not price: + abort(404, message="Plan not found") + + return change_subscription(organization, price) raise Unauthorized() diff --git a/endpoints/api/subscribe.py b/endpoints/api/subscribe.py index 5afa533b4..20247de4d 100644 --- a/endpoints/api/subscribe.py +++ b/endpoints/api/subscribe.py @@ -52,7 +52,8 @@ def subscription_view(stripe_subscription, used_repos): return view -def subscribe(user, plan, token, require_business_plan): +def get_price(plan, require_business_plan): + """Billing Price (previously stripe Plan) from id.""" if not features.BILLING: return @@ -69,6 +70,10 @@ def subscribe(user, plan, token, require_business_plan): logger.warning("Business attempting to subscribe to personal plan: %s", user.username) raise request_error(message="No matching plan found") + return plan_found + + +def change_subscription(user, plan): private_repos = model.get_private_repo_count(user.username) # This is the default response @@ -78,66 +83,36 @@ def subscribe(user, plan, token, require_business_plan): } status_code = 200 - if not user.stripe_id: - # Check if a non-paying user is trying to subscribe to a free plan - if not plan_found["price"] == 0: - # They want a real paying plan, create the customer and plan - # simultaneously - card = token + try: + cus = billing.Customer.retrieve(user.stripe_id) + except stripe.error.APIConnectionError as e: + return connection_response(e) + if plan["price"] == 0: + if cus.subscription is not None: + # We only have to cancel the subscription if they actually have one try: - cus = billing.Customer.create( - email=user.email, - plan=plan, - card=card, - payment_behavior="default_incomplete", - # API versions prior to 2019-03-14 defaults to 'error_if_incomplete' - ) - user.stripe_id = cus.id - user.save() - check_repository_usage(user, plan_found) - log_action("account_change_plan", user.username, {"plan": plan}) - except stripe.error.CardError as e: - return carderror_response(e) + billing.Subscription.delete(cus.subscription.id) except stripe.error.APIConnectionError as e: return connection_response(e) - response_json = subscription_view(cus.subscription, private_repos) - status_code = 201 + check_repository_usage(user, plan) + log_action("account_change_plan", user.username, {"plan": plan["stripeId"]}) else: - # Change the plan + # User may have been a previous customer who is resubscribing + modify_cus_args = {"plan": plan["stripeId"], "payment_behavior": "default_incomplete"} + try: - cus = billing.Customer.retrieve(user.stripe_id) + billing.Customer.modify(cus.id, **modify_cus_args) + except stripe.error.CardError as e: + return carderror_response(e) except stripe.error.APIConnectionError as e: return connection_response(e) - if plan_found["price"] == 0: - if cus.subscription is not None: - # We only have to cancel the subscription if they actually have one - try: - billing.Subscription.delete(cus.subscription.id) - except stripe.error.APIConnectionError as e: - return connection_response(e) - - check_repository_usage(user, plan_found) - log_action("account_change_plan", user.username, {"plan": plan}) - - else: - # User may have been a previous customer who is resubscribing - modify_cus_args = {"plan": plan, "payment_behavior": "default_incomplete"} - if token: - modify_cus_args["card"] = token - - try: - billing.Customer.modify(cus.id, **modify_cus_args) - except stripe.error.CardError as e: - return carderror_response(e) - except stripe.error.APIConnectionError as e: - return connection_response(e) - - response_json = subscription_view(cus.subscription, private_repos) - check_repository_usage(user, plan_found) - log_action("account_change_plan", user.username, {"plan": plan}) + cus = cus.refresh() + response_json = subscription_view(cus.subscription, private_repos) + check_repository_usage(user, plan) + log_action("account_change_plan", user.username, {"plan": plan["stripeId"]}) return response_json, status_code diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 3a269b86b..1db4e7e8c 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -346,12 +346,27 @@ SECURITY_TESTS: List[ (UserCard, "GET", None, None, "devtable", 200), (UserCard, "GET", None, None, "freshuser", 200), (UserCard, "GET", None, None, "reader", 200), - (UserCard, "POST", None, {"token": "ORH4"}, None, 401), + (UserCard, "POST", None, {"success_url": "http://foo", "cancel_url": "http://bar"}, None, 401), (UserPlan, "GET", None, None, None, 401), (UserPlan, "GET", None, None, "devtable", 200), (UserPlan, "GET", None, None, "freshuser", 200), (UserPlan, "GET", None, None, "reader", 200), - (UserPlan, "PUT", None, {"plan": "1QIK"}, None, 401), + ( + UserPlan, + "POST", + None, + {"plan": "1QIK", "success_url": "http://foo", "cancel_url": "http://bar"}, + None, + 401, + ), + ( + UserPlan, + "PUT", + None, + {"plan": "1QIK", "success_url": "http://foo", "cancel_url": "http://bar"}, + None, + 401, + ), (UserLogs, "GET", None, None, None, 401), (UserLogs, "GET", None, None, "devtable", 200), (UserLogs, "GET", None, None, "freshuser", 200), @@ -3976,13 +3991,42 @@ SECURITY_TESTS: List[ (OrganizationCard, "GET", {"orgname": "buynlarge"}, None, "devtable", 200), (OrganizationCard, "GET", {"orgname": "buynlarge"}, None, "freshuser", 403), (OrganizationCard, "GET", {"orgname": "buynlarge"}, None, "reader", 403), - (OrganizationCard, "POST", {"orgname": "buynlarge"}, {"token": "4VFR"}, None, 401), - (OrganizationCard, "POST", {"orgname": "buynlarge"}, {"token": "4VFR"}, "freshuser", 403), - (OrganizationCard, "POST", {"orgname": "buynlarge"}, {"token": "4VFR"}, "reader", 403), + ( + OrganizationCard, + "POST", + {"orgname": "buynlarge"}, + {"success_url": "http://foo", "cancel_url": "http://bar"}, + None, + 401, + ), + ( + OrganizationCard, + "POST", + {"orgname": "buynlarge"}, + {"success_url": "http://foo", "cancel_url": "http://bar"}, + "freshuser", + 403, + ), + ( + OrganizationCard, + "POST", + {"orgname": "buynlarge"}, + {"success_url": "http://foo", "cancel_url": "http://bar"}, + "reader", + 403, + ), (OrganizationPlan, "GET", {"orgname": "buynlarge"}, None, None, 401), (OrganizationPlan, "GET", {"orgname": "buynlarge"}, None, "devtable", 200), (OrganizationPlan, "GET", {"orgname": "buynlarge"}, None, "freshuser", 403), (OrganizationPlan, "GET", {"orgname": "buynlarge"}, None, "reader", 403), + ( + OrganizationPlan, + "POST", + {"orgname": "buynlarge"}, + {"plan": "WWEI", "success_url": "http://foo", "cancel_url": "http://bar"}, + None, + 401, + ), (OrganizationPlan, "PUT", {"orgname": "buynlarge"}, {"plan": "WWEI"}, None, 401), (OrganizationPlan, "PUT", {"orgname": "buynlarge"}, {"plan": "WWEI"}, "freshuser", 403), (OrganizationPlan, "PUT", {"orgname": "buynlarge"}, {"plan": "WWEI"}, "reader", 403), diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 6ac8d96df..548181aa2 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -59,7 +59,7 @@ from endpoints.api import ( page_support, ) from endpoints.exception import NotFound, InvalidToken, InvalidRequest, DownstreamIssue -from endpoints.api.subscribe import subscribe +from endpoints.api.subscribe import change_subscription, get_price from endpoints.common import common_login from endpoints.csrf import generate_csrf_token, OAUTH_CSRF_TOKEN_NAME from endpoints.decorators import ( @@ -734,7 +734,8 @@ class ConvertToOrganization(ApiResource): # Subscribe the organization to the new plan. if features.BILLING: plan = convert_data.get("plan", "free") - subscribe(user, plan, None, True) # Require business plans + price = get_price(plan, True) + change_subscription(user, price) # Require business plans # Convert the user to an organization. model.organization.convert_user_to_organization(user, admin_user) diff --git a/endpoints/common.py b/endpoints/common.py index c6dfeb9b4..1d7f127bb 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -113,7 +113,7 @@ def render_page_template(name, route_data=None, **kwargs): # Add Stripe checkout if billing is enabled. if features.BILLING: - external_scripts.append("//checkout.stripe.com/checkout.js") + external_scripts.append("//js.stripe.com/v3/") has_contact = len(app.config.get("CONTACT_INFO", [])) > 0 contact_href = None diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py index c3332850f..c4ae00fa0 100644 --- a/endpoints/webhooks.py +++ b/endpoints/webhooks.py @@ -4,6 +4,7 @@ from flask import request, make_response, Blueprint from app import billing as stripe, app from data import model +from data.logs_model import logs_model from data.database import RepositoryState from auth.decorators import process_auth from auth.permissions import ModifyRepositoryPermission @@ -30,6 +31,18 @@ webhooks = Blueprint("webhooks", __name__) @webhooks.route("/stripe", methods=["POST"]) def stripe_webhook(): + def _stripe_checkout_log_action(kind, namespace_name, performer_name, ip, metadata=None): + if not metadata: + metadata = {} + + performer = data.model.users.get_user(performer_name) + logs_model.log_action( + kind, + namespace_name, + performer=performer, + ip=ip, + ) + request_data = request.get_json() logger.debug("Stripe webhook call: %s", request_data) @@ -83,6 +96,47 @@ def stripe_webhook(): if namespace: send_payment_failed(namespace.email, namespace.username) + elif event_type == "checkout.session.completed": + mode = request_data["data"]["object"]["mode"] + + if mode == "setup": + setup_intent = stripe.SetupIntent.retrieve( + request_data["data"]["object"]["setup_intent"] + ) + setup_intent_metadata = setup_intent["metadata"] + + payment_method = setup_intent["payment_method"] + customer = setup_intent["customer"] + subscription = setup_intent_metadata["subscription_id"] + + stripe.Customer.modify( + customer, + invoice_settings={"default_payment_method": payment_method}, + ) + stripe.Subscription.modify( + subscription, + default_payment_method=payment_method, + ) + + _stripe_checkout_log_action( + setup_intent_metadata["kind"], + setup_intent_metadata["namespace"], + setup_intent_metadata["performer"], + setup_intent_metadata["ip"], + ) + + elif mode == "subscription": + sub = stripe.Subscription.retrieve(request_data["data"]["object"]["subscription"]) + sub_metadata = sub["metadata"] + + _stripe_checkout_log_action( + sub_metadata["kind"], + sub_metadata["namespace"], + sub_metadata["performer"], + sub_metadata["ip"], + metadata={"plan": sub_metadata["plan"]}, + ) + return make_response("Okay") diff --git a/static/js/services/plan-service.js b/static/js/services/plan-service.js index fd275605e..6f77e0a10 100644 --- a/static/js/services/plan-service.js +++ b/static/js/services/plan-service.js @@ -2,9 +2,8 @@ * Helper service for loading, changing and working with subscription plans. */ angular.module('quay') - .factory('PlanService', ['KeyService', 'UserService', 'CookieService', 'ApiService', 'Features', 'Config', '$location', - -function(KeyService, UserService, CookieService, ApiService, Features, Config, $location) { + .factory('PlanService', ['KeyService', 'UserService', 'CookieService', 'ApiService', 'Features', 'Config', '$location', '$timeout', +function(KeyService, UserService, CookieService, ApiService, Features, Config, $location, $timeout) { var plans = null; var planDict = {}; var planService = {}; @@ -176,27 +175,47 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $ ApiService.getSubscription(orgname).then(success, failure); }; - planService.setSubscription = function(orgname, planId, success, failure, opt_token) { + planService.createSubscription = function($scope, orgname, planId, success, failure) { if (!Features.BILLING) { return; } + var redirectURL = $scope.redirectUrl || window.location.toString(); var subscriptionDetails = { - plan: planId + plan: planId, + success_url: redirectURL, + cancel_url: redirectURL }; - if (opt_token) { - subscriptionDetails['token'] = opt_token.id; - } - - ApiService.updateSubscription(orgname, subscriptionDetails).then(function(resp) { - success(resp); - planService.getPlan(planId, function(plan) { - for (var i = 0; i < listeners.length; ++i) { - listeners[i]['callback'](plan); - } - }); - }, failure); + ApiService.createSubscription(orgname, subscriptionDetails).then( + function(resp) { + $timeout(function() { + success(resp); + document.location = resp.url; + }, 250); + }, + failure + ); }; + planService.updateSubscription = function($scope, orgname, planId, success, failure) { + if (!Features.BILLING) { return; } + + var subscriptionDetails = { + plan: planId, + }; + + planService.getPlan(planId, function(plan) { + ApiService.updateSubscription(orgname, subscriptionDetails).then( + function(resp) { + success(resp); + for (var i = 0; i < listeners.length; ++i) { + listeners[i]['callback'](plan); + } + }, + failure + ); + }); + }; + planService.getCardInfo = function(orgname, callback) { if (!Features.BILLING) { return; } @@ -232,19 +251,29 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $ if (orgname && !planService.isOrgCompatible(plan)) { return; } planService.getCardInfo(orgname, function(cardInfo) { + if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4 || !opt_reuseCard)) { - var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)'; - planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true); + var redirectURL = $scope.redirectUrl || window.location.toString(); + var setCardDetails = { + success_url: redirectURL, + cancel_url: redirectURL + }; + + planService.createSubscription($scope, orgname, planId, callbacks['success'], function(resp) { + previousSubscribeFailure = true; + planService.handleCardError(resp); + callbacks['failure'](resp); + }); return; } previousSubscribeFailure = false; - planService.setSubscription(orgname, planId, callbacks['success'], function(resp) { - previousSubscribeFailure = true; - planService.handleCardError(resp); - callbacks['failure'](resp); - }); + planService.updateSubscription($scope, orgname, planId, callbacks['success'], function(resp) { + previousSubscribeFailure = true; + planService.handleCardError(resp); + callbacks['failure'](resp); + }); }); }); }; @@ -256,126 +285,26 @@ function(KeyService, UserService, CookieService, ApiService, Features, Config, $ callbacks['opening'](); } - var submitted = false; - var submitToken = function(token) { - if (submitted) { return; } - submitted = true; - $scope.$apply(function() { - if (callbacks['started']) { - callbacks['started'](); - } - - var cardInfo = { - 'token': token.id - }; - - ApiService.setCard(orgname, cardInfo).then(callbacks['success'], function(resp) { - planService.handleCardError(resp); - callbacks['failure'](resp); - }); - }); + var redirectURL = $scope.redirectUrl || window.location.toString(); + var setCardDetails = { + success_url: redirectURL, + cancel_url: redirectURL }; - var email = planService.getEmail(orgname); - StripeCheckout.open({ - key: KeyService.stripePublishableKey, - email: email, - currency: 'usd', - name: 'Update credit card', - description: 'Enter your credit card number', - panelLabel: 'Update', - token: submitToken, - image: 'static/img/quay-icon-stripe.png', - billingAddress: true, - zipCode: true, - opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, - closed: function() { $scope.$apply(function() { callbacks['closed']() }); } - }); - }; - - planService.getEmail = function(orgname) { - var email = null; - if (UserService.currentUser()) { - email = UserService.currentUser().email; - - if (orgname) { - org = UserService.getOrganization(orgname); - if (org) { - emaiil = org.email; - } + ApiService.setCard(orgname, setCardDetails).then( + function(resp) { + $timeout(function() { + callbacks['success'](resp) + document.location = resp.url; + }, 250); + + }, + function(resp) { + planService.handleCardError(resp); + callbacks['failure'](resp); } - } - return email; - }; - - planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) { - if (!Features.BILLING) { return; } - - // If the async parameter is true and this is a browser that does not allow async popup of the - // Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead. - var isIE = navigator.appName.indexOf("Internet Explorer") != -1; - var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/); - - if (opt_async && (isIE || isMobileSafari)) { - bootbox.dialog({ - "message": "Please click 'Subscribe' to continue", - "buttons": { - "subscribe": { - "label": "Subscribe", - "className": "btn-primary", - "callback": function() { - planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false); - } - }, - "close": { - "label": "Cancel", - "className": "btn-default" - } - } - }); - return; - } - - if (callbacks['opening']) { - callbacks['opening'](); - } - - var submitted = false; - var submitToken = function(token) { - if (submitted) { return; } - submitted = true; - - if (Config.MIXPANEL_KEY) { - mixpanel.track('plan_subscribe'); - } - - $scope.$apply(function() { - if (callbacks['started']) { - callbacks['started'](); - } - planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token); - }); - }; - - planService.getPlan(planId, function(planDetails) { - var email = planService.getEmail(orgname); - StripeCheckout.open({ - key: KeyService.stripePublishableKey, - email: email, - amount: planDetails.price, - currency: 'usd', - name: 'Quay ' + planDetails.title + ' Subscription', - description: 'Up to ' + planDetails.privateRepos + ' private repositories', - panelLabel: opt_title || 'Subscribe', - token: submitToken, - image: 'static/img/quay-icon-stripe.png', - billingAddress: true, - zipCode: true, - opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, - closed: function() { $scope.$apply(function() { callbacks['closed']() }); } - }); - }); + ); }; return planService; -}]); \ No newline at end of file +}]); diff --git a/test/test_api_usage.py b/test/test_api_usage.py index c34a21d72..6a3e479f1 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -3605,8 +3605,8 @@ class TestUserCard(ApiTestCase): def test_setusercard_error(self): self.login(ADMIN_ACCESS_USER) - json = self.postJsonResponse(UserCard, data=dict(token="sometoken"), expected_code=402) - assert "carderror" in json + # token not a valid param anymore. This becomes a checkout session instead + json = self.postJsonResponse(UserCard, data=dict(token="sometoken"), expected_code=400) class TestOrgCard(ApiTestCase):