This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/endpoints/api/billing.py
Joseph Schorr e7fa560787 Add support for custom fields in billing invoices
Customers (especially in Europe) need the ability to add Tax IDs, VAT IDs, and other custom fields to their invoices.

Fixes #106
2015-06-12 16:45:01 -04:00

569 lines
16 KiB
Python

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, Unauthorized, NotFound,
require_user_admin, show_if, hide_if, path_param, require_scope, abort)
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
import features
import uuid
import json
def carderror_response(e):
return {'carderror': e.message}, 402
def get_card(user):
card_info = {
'is_valid': False
}
if user.stripe_id:
try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.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.APIConnectionError as e:
abort(503, message='Cannot contact Stripe')
if cus:
try:
cus.card = token
cus.save()
except stripe.CardError as exc:
return carderror_response(exc)
except stripe.InvalidRequestError as exc:
return carderror_response(exc)
except stripe.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.all(customer=customer_id, count=12)
except stripe.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.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.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.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.get_private_repo_count(user.username)
if user.stripe_id:
try:
cus = billing.Customer.retrieve(user.stripe_id)
except stripe.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.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.get_private_repo_count(orgname)
organization = model.get_organization(orgname)
if organization.stripe_id:
try:
cus = billing.Customer.retrieve(organization.stripe_id)
except stripe.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 OrgnaizationInvoiceList(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.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.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
return {'fields': get_invoice_fields(organization)[0]}
abort(403)
@require_scope(scopes.ORG_ADMIN)
@nickname('createOrgInvoiceField')
@validate_json_request('InvoiceField')
def post(self, orgname):
""" Creates a new invoice field. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
organization = model.get_organization(orgname)
if not organization.stripe_id:
raise NotFound()
data = request.get_json()
created_field = create_billing_invoice_field(organization, data['title'], data['value'])
return created_field
abort(403)
@resource('/v1/organization/<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.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)