608 lines
17 KiB
Python
608 lines
17 KiB
Python
|
""" Billing information, subscriptions, and plan information. """
|
||
|
|
||
|
import stripe
|
||
|
|
||
|
from flask import request
|
||
|
from app import billing
|
||
|
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action,
|
||
|
related_user_resource, internal_only, require_user_admin, show_if,
|
||
|
path_param, require_scope, abort)
|
||
|
from endpoints.exception import Unauthorized, NotFound
|
||
|
from endpoints.api.subscribe import subscribe, subscription_view
|
||
|
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
|
||
|
|
||
|
import features
|
||
|
import uuid
|
||
|
import json
|
||
|
|
||
|
def get_namespace_plan(namespace):
|
||
|
""" Returns the plan of the given namespace. """
|
||
|
namespace_user = model.user.get_namespace_user(namespace)
|
||
|
if namespace_user is None:
|
||
|
return None
|
||
|
|
||
|
if not namespace_user.stripe_id:
|
||
|
return None
|
||
|
|
||
|
# Ask Stripe for the subscribed plan.
|
||
|
# TODO: Can we cache this or make it faster somehow?
|
||
|
try:
|
||
|
cus = billing.Customer.retrieve(namespace_user.stripe_id)
|
||
|
except stripe.error.APIConnectionError:
|
||
|
abort(503, message='Cannot contact Stripe')
|
||
|
|
||
|
if not cus.subscription:
|
||
|
return None
|
||
|
|
||
|
return get_plan(cus.subscription.plan.id)
|
||
|
|
||
|
|
||
|
def lookup_allowed_private_repos(namespace):
|
||
|
""" Returns false if the given namespace has used its allotment of private repositories. """
|
||
|
current_plan = get_namespace_plan(namespace)
|
||
|
if current_plan is None:
|
||
|
return False
|
||
|
|
||
|
# Find the number of private repositories used by the namespace and compare it to the
|
||
|
# plan subscribed.
|
||
|
private_repos = model.user.get_private_repo_count(namespace)
|
||
|
|
||
|
return private_repos < current_plan['privateRepos']
|
||
|
|
||
|
|
||
|
def carderror_response(e):
|
||
|
return {'carderror': str(e)}, 402
|
||
|
|
||
|
|
||
|
def get_card(user):
|
||
|
card_info = {
|
||
|
'is_valid': False
|
||
|
}
|
||
|
|
||
|
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 and cus.default_card:
|
||
|
# Find the default card.
|
||
|
default_card = None
|
||
|
for card in cus.cards.data:
|
||
|
if card.id == cus.default_card:
|
||
|
default_card = card
|
||
|
break
|
||
|
|
||
|
if default_card:
|
||
|
card_info = {
|
||
|
'owner': default_card.name,
|
||
|
'type': default_card.type,
|
||
|
'last4': default_card.last4,
|
||
|
'exp_month': default_card.exp_month,
|
||
|
'exp_year': default_card.exp_year
|
||
|
}
|
||
|
|
||
|
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 {
|
||
|
'id': i.id,
|
||
|
'date': i.date,
|
||
|
'period_start': i.period_start,
|
||
|
'period_end': i.period_end,
|
||
|
'paid': i.paid,
|
||
|
'amount_due': i.amount_due,
|
||
|
'next_payment_attempt': i.next_payment_attempt,
|
||
|
'attempted': i.attempted,
|
||
|
'closed': i.closed,
|
||
|
'total': i.total,
|
||
|
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
|
||
|
}
|
||
|
|
||
|
try:
|
||
|
invoices = billing.Invoice.list(customer=customer_id, count=12)
|
||
|
except stripe.error.APIConnectionError as e:
|
||
|
abort(503, message='Cannot contact Stripe')
|
||
|
|
||
|
return {
|
||
|
'invoices': [invoice_view(i) for i in invoices.data]
|
||
|
}
|
||
|
|
||
|
|
||
|
def get_invoice_fields(user):
|
||
|
try:
|
||
|
cus = billing.Customer.retrieve(user.stripe_id)
|
||
|
except stripe.error.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):
|
||
|
""" Resource for listing the available plans. """
|
||
|
@nickname('listPlans')
|
||
|
def get(self):
|
||
|
""" List the avaialble plans. """
|
||
|
return {
|
||
|
'plans': PLANS,
|
||
|
}
|
||
|
|
||
|
|
||
|
@resource('/v1/user/card')
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class UserCard(ApiResource):
|
||
|
""" Resource for managing a user's credit card. """
|
||
|
schemas = {
|
||
|
'UserCard': {
|
||
|
'id': 'UserCard',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of a user card',
|
||
|
'required': [
|
||
|
'token',
|
||
|
],
|
||
|
'properties': {
|
||
|
'token': {
|
||
|
'type': 'string',
|
||
|
'description': 'Stripe token that is generated by stripe checkout.js',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('getUserCard')
|
||
|
def get(self):
|
||
|
""" Get the user's credit card. """
|
||
|
user = get_authenticated_user()
|
||
|
return get_card(user)
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('setUserCard')
|
||
|
@validate_json_request('UserCard')
|
||
|
def post(self):
|
||
|
""" 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
|
||
|
|
||
|
|
||
|
@resource('/v1/organization/<orgname>/card')
|
||
|
@path_param('orgname', 'The name of the organization')
|
||
|
@internal_only
|
||
|
@related_user_resource(UserCard)
|
||
|
@show_if(features.BILLING)
|
||
|
class OrganizationCard(ApiResource):
|
||
|
""" Resource for managing an organization's credit card. """
|
||
|
schemas = {
|
||
|
'OrgCard': {
|
||
|
'id': 'OrgCard',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of a user card',
|
||
|
'required': [
|
||
|
'token',
|
||
|
],
|
||
|
'properties': {
|
||
|
'token': {
|
||
|
'type': 'string',
|
||
|
'description': 'Stripe token that is generated by stripe checkout.js',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('getOrgCard')
|
||
|
def get(self, orgname):
|
||
|
""" Get the organization's credit card. """
|
||
|
permission = AdministerOrganizationPermission(orgname)
|
||
|
if permission.can():
|
||
|
organization = model.organization.get_organization(orgname)
|
||
|
return get_card(organization)
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
@nickname('setOrgCard')
|
||
|
@validate_json_request('OrgCard')
|
||
|
def post(self, orgname):
|
||
|
""" Update the orgnaization's credit card. """
|
||
|
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
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
|
||
|
@resource('/v1/user/plan')
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class UserPlan(ApiResource):
|
||
|
""" Resource for managing a user's subscription. """
|
||
|
schemas = {
|
||
|
'UserSubscription': {
|
||
|
'id': 'UserSubscription',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of a user card',
|
||
|
'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',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('updateUserSubscription')
|
||
|
@validate_json_request('UserSubscription')
|
||
|
def put(self):
|
||
|
""" Create or update the user's 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
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('getUserSubscription')
|
||
|
def get(self):
|
||
|
""" Fetch any existing subscription for the user. """
|
||
|
cus = None
|
||
|
user = get_authenticated_user()
|
||
|
private_repos = model.user.get_private_repo_count(user.username)
|
||
|
|
||
|
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.subscription:
|
||
|
return subscription_view(cus.subscription, private_repos)
|
||
|
|
||
|
return {
|
||
|
'hasSubscription': False,
|
||
|
'isExistingCustomer': cus is not None,
|
||
|
'plan': 'free',
|
||
|
'usedPrivateRepos': private_repos,
|
||
|
}
|
||
|
|
||
|
|
||
|
@resource('/v1/organization/<orgname>/plan')
|
||
|
@path_param('orgname', 'The name of the organization')
|
||
|
@internal_only
|
||
|
@related_user_resource(UserPlan)
|
||
|
@show_if(features.BILLING)
|
||
|
class OrganizationPlan(ApiResource):
|
||
|
""" Resource for managing a org's subscription. """
|
||
|
schemas = {
|
||
|
'OrgSubscription': {
|
||
|
'id': 'OrgSubscription',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of a user card',
|
||
|
'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',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('updateOrgSubscription')
|
||
|
@validate_json_request('OrgSubscription')
|
||
|
def put(self, orgname):
|
||
|
""" Create or update the org's subscription. """
|
||
|
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
|
||
|
organization = model.organization.get_organization(orgname)
|
||
|
return subscribe(organization, plan, token, True) # Business plan required
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('getOrgSubscription')
|
||
|
def get(self, orgname):
|
||
|
""" Fetch any existing subscription for the org. """
|
||
|
cus = None
|
||
|
permission = AdministerOrganizationPermission(orgname)
|
||
|
if permission.can():
|
||
|
private_repos = model.user.get_private_repo_count(orgname)
|
||
|
organization = model.organization.get_organization(orgname)
|
||
|
if organization.stripe_id:
|
||
|
try:
|
||
|
cus = billing.Customer.retrieve(organization.stripe_id)
|
||
|
except stripe.error.APIConnectionError as e:
|
||
|
abort(503, message='Cannot contact Stripe')
|
||
|
|
||
|
if cus.subscription:
|
||
|
return subscription_view(cus.subscription, private_repos)
|
||
|
|
||
|
return {
|
||
|
'hasSubscription': False,
|
||
|
'isExistingCustomer': cus is not None,
|
||
|
'plan': 'free',
|
||
|
'usedPrivateRepos': private_repos,
|
||
|
}
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
|
||
|
@resource('/v1/user/invoices')
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class UserInvoiceList(ApiResource):
|
||
|
""" Resource for listing a user's invoices. """
|
||
|
@require_user_admin
|
||
|
@nickname('listUserInvoices')
|
||
|
def get(self):
|
||
|
""" List the invoices for the current user. """
|
||
|
user = get_authenticated_user()
|
||
|
if not user.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
return get_invoices(user.stripe_id)
|
||
|
|
||
|
|
||
|
@resource('/v1/organization/<orgname>/invoices')
|
||
|
@path_param('orgname', 'The name of the organization')
|
||
|
@related_user_resource(UserInvoiceList)
|
||
|
@show_if(features.BILLING)
|
||
|
class OrganizationInvoiceList(ApiResource):
|
||
|
""" Resource for listing an orgnaization's invoices. """
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('listOrgInvoices')
|
||
|
def get(self, orgname):
|
||
|
""" List the invoices for the specified orgnaization. """
|
||
|
permission = AdministerOrganizationPermission(orgname)
|
||
|
if permission.can():
|
||
|
organization = model.organization.get_organization(orgname)
|
||
|
if not organization.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
return get_invoices(organization.stripe_id)
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
|
||
|
@resource('/v1/user/invoice/fields')
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class UserInvoiceFieldList(ApiResource):
|
||
|
""" Resource for listing and creating a user's custom invoice fields. """
|
||
|
schemas = {
|
||
|
'InvoiceField': {
|
||
|
'id': 'InvoiceField',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of an invoice field',
|
||
|
'required': [
|
||
|
'title', 'value'
|
||
|
],
|
||
|
'properties': {
|
||
|
'title': {
|
||
|
'type': 'string',
|
||
|
'description': 'The title of the field being added',
|
||
|
},
|
||
|
'value': {
|
||
|
'type': 'string',
|
||
|
'description': 'The value of the field being added',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('listUserInvoiceFields')
|
||
|
def get(self):
|
||
|
""" List the invoice fields for the current user. """
|
||
|
user = get_authenticated_user()
|
||
|
if not user.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
return {'fields': get_invoice_fields(user)[0]}
|
||
|
|
||
|
@require_user_admin
|
||
|
@nickname('createUserInvoiceField')
|
||
|
@validate_json_request('InvoiceField')
|
||
|
def post(self):
|
||
|
""" Creates a new invoice field. """
|
||
|
user = get_authenticated_user()
|
||
|
if not user.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
data = request.get_json()
|
||
|
created_field = create_billing_invoice_field(user, data['title'], data['value'])
|
||
|
return created_field
|
||
|
|
||
|
|
||
|
@resource('/v1/user/invoice/field/<field_uuid>')
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class UserInvoiceField(ApiResource):
|
||
|
""" Resource for deleting a user's custom invoice fields. """
|
||
|
@require_user_admin
|
||
|
@nickname('deleteUserInvoiceField')
|
||
|
def delete(self, field_uuid):
|
||
|
""" Deletes the invoice field for the current user. """
|
||
|
user = get_authenticated_user()
|
||
|
if not user.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
result = delete_billing_invoice_field(user, field_uuid)
|
||
|
if not result:
|
||
|
abort(404)
|
||
|
|
||
|
return 'Okay', 201
|
||
|
|
||
|
|
||
|
@resource('/v1/organization/<orgname>/invoice/fields')
|
||
|
@path_param('orgname', 'The name of the organization')
|
||
|
@related_user_resource(UserInvoiceFieldList)
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class OrganizationInvoiceFieldList(ApiResource):
|
||
|
""" Resource for listing and creating an organization's custom invoice fields. """
|
||
|
schemas = {
|
||
|
'InvoiceField': {
|
||
|
'id': 'InvoiceField',
|
||
|
'type': 'object',
|
||
|
'description': 'Description of an invoice field',
|
||
|
'required': [
|
||
|
'title', 'value'
|
||
|
],
|
||
|
'properties': {
|
||
|
'title': {
|
||
|
'type': 'string',
|
||
|
'description': 'The title of the field being added',
|
||
|
},
|
||
|
'value': {
|
||
|
'type': 'string',
|
||
|
'description': 'The value of the field being added',
|
||
|
},
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('listOrgInvoiceFields')
|
||
|
def get(self, orgname):
|
||
|
""" List the invoice fields for the organization. """
|
||
|
permission = AdministerOrganizationPermission(orgname)
|
||
|
if permission.can():
|
||
|
organization = model.organization.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.organization.get_organization(orgname)
|
||
|
if not organization.stripe_id:
|
||
|
raise NotFound()
|
||
|
|
||
|
data = request.get_json()
|
||
|
created_field = create_billing_invoice_field(organization, data['title'], data['value'])
|
||
|
return created_field
|
||
|
|
||
|
abort(403)
|
||
|
|
||
|
|
||
|
@resource('/v1/organization/<orgname>/invoice/field/<field_uuid>')
|
||
|
@path_param('orgname', 'The name of the organization')
|
||
|
@related_user_resource(UserInvoiceField)
|
||
|
@internal_only
|
||
|
@show_if(features.BILLING)
|
||
|
class OrganizationInvoiceField(ApiResource):
|
||
|
""" Resource for deleting an organization's custom invoice fields. """
|
||
|
@require_scope(scopes.ORG_ADMIN)
|
||
|
@nickname('deleteOrgInvoiceField')
|
||
|
def delete(self, orgname, field_uuid):
|
||
|
""" Deletes the invoice field for the current user. """
|
||
|
permission = AdministerOrganizationPermission(orgname)
|
||
|
if permission.can():
|
||
|
organization = model.organization.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)
|