mirror of
https://github.com/quay/quay.git
synced 2025-07-30 07:43:13 +03:00
billing: update Stripe checkout to support 3DS (PROJQUAY-5129) (#1818)
Update Stripe checkout in order to support auth requirements from banks.
This commit is contained in:
committed by
GitHub
parent
fa50c70ed0
commit
89725309be
@ -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
|
||||
|
||||
|
||||
|
@ -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/<orgname>/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()
|
||||
|
||||
|
@ -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,41 +83,12 @@ 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.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)
|
||||
except stripe.error.APIConnectionError as e:
|
||||
return connection_response(e)
|
||||
|
||||
response_json = subscription_view(cus.subscription, private_repos)
|
||||
status_code = 201
|
||||
|
||||
else:
|
||||
# Change the plan
|
||||
try:
|
||||
cus = billing.Customer.retrieve(user.stripe_id)
|
||||
except stripe.error.APIConnectionError as e:
|
||||
return connection_response(e)
|
||||
|
||||
if plan_found["price"] == 0:
|
||||
if plan["price"] == 0:
|
||||
if cus.subscription is not None:
|
||||
# We only have to cancel the subscription if they actually have one
|
||||
try:
|
||||
@ -120,14 +96,12 @@ def subscribe(user, plan, token, require_business_plan):
|
||||
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})
|
||||
check_repository_usage(user, plan)
|
||||
log_action("account_change_plan", user.username, {"plan": plan["stripeId"]})
|
||||
|
||||
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
|
||||
modify_cus_args = {"plan": plan["stripeId"], "payment_behavior": "default_incomplete"}
|
||||
|
||||
try:
|
||||
billing.Customer.modify(cus.id, **modify_cus_args)
|
||||
@ -136,8 +110,9 @@ def subscribe(user, plan, token, require_business_plan):
|
||||
except stripe.error.APIConnectionError as e:
|
||||
return connection_response(e)
|
||||
|
||||
cus = cus.refresh()
|
||||
response_json = subscription_view(cus.subscription, private_repos)
|
||||
check_repository_usage(user, plan_found)
|
||||
log_action("account_change_plan", user.username, {"plan": plan})
|
||||
check_repository_usage(user, plan)
|
||||
log_action("account_change_plan", user.username, {"plan": plan["stripeId"]})
|
||||
|
||||
return response_json, status_code
|
||||
|
@ -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),
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
||||
|
@ -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,25 +175,45 @@ 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,
|
||||
success_url: redirectURL,
|
||||
cancel_url: redirectURL
|
||||
};
|
||||
|
||||
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
|
||||
plan: planId,
|
||||
};
|
||||
|
||||
if (opt_token) {
|
||||
subscriptionDetails['token'] = opt_token.id;
|
||||
}
|
||||
|
||||
ApiService.updateSubscription(orgname, subscriptionDetails).then(function(resp) {
|
||||
success(resp);
|
||||
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
|
||||
);
|
||||
});
|
||||
}, failure);
|
||||
};
|
||||
|
||||
planService.getCardInfo = function(orgname, callback) {
|
||||
@ -232,15 +251,25 @@ 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) {
|
||||
planService.updateSubscription($scope, orgname, planId, callbacks['success'], function(resp) {
|
||||
previousSubscribeFailure = true;
|
||||
planService.handleCardError(resp);
|
||||
callbacks['failure'](resp);
|
||||
@ -256,125 +285,25 @@ 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
|
||||
var redirectURL = $scope.redirectUrl || window.location.toString();
|
||||
var setCardDetails = {
|
||||
success_url: redirectURL,
|
||||
cancel_url: redirectURL
|
||||
};
|
||||
|
||||
ApiService.setCard(orgname, cardInfo).then(callbacks['success'], function(resp) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
@ -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):
|
||||
|
Reference in New Issue
Block a user