diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c6b9875 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit= + billy/tests/* diff --git a/.gitignore b/.gitignore index a2d5aeb..17f017e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ develop-eggs .installed.cfg lib lib64 +cover # Installer logs pip-log.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d9b0562 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "chef"] + path = chef + url = https://github.com/victorlin/billy-chef.git diff --git a/MAINFEST.in b/MAINFEST.in index fdf385a..de48ed3 100644 --- a/MAINFEST.in +++ b/MAINFEST.in @@ -1,3 +1,3 @@ include distribute_setup.py recursive-include billy -prune env \ No newline at end of file +prune env diff --git a/README.md b/README.md index 7163edb..01adc95 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,87 @@ Billy - The Open Source Recurring Billing System, powered by Balanced ## Running It -There are three major parts to billy: the models, the api, and the web layer. -This library currently has the API and the models. +To run billy (development mode), you need to install the package first. +As we don't want to mess the global Python environment, you should +create a virtual environmnet first and switch to it -1. Create a pgsql DB called 'billy' with 'test' user and no password -2. Install requirements ```python setup.py develop``` -3. Create the tables: ```python manage.py create_tables``` -4. To run the api server run: ```python manage.py run_api``` -5. Cron job this hourly: ```python manage.py billy_tasks``` +``` +virtualenv --no-site-packages env +source env/bin/activate +``` -Congrats. You've got recurring billing. +If above works correctly, you should see -## Models +``` +(env) $ +``` -The models should have all the methods necessary to bill recuringly. Generally, -modifactions to the underlying data should be done via the model methods not, -directly. This is to ensure that all the accounting is handled correctly. +in you command line tool. The `(env)` indicates that you are currently +in the virtual Python environment. Then you need to install the billy project. +Here you run -To see how they work check out the interface tests -(tests/models/test_interface.py) +``` +python setup.py develop +``` -## Api +This should install all required dependencies. Then you need to create +tables in database, here you type -Check out the spec at api/spec.json, which is generated using api/spec.py +``` +initialize_billy_db development.ini +``` +This should create all necessary tables for you in a default SQLite database. -#### Major Todos: -- Redo import strucutre -- Redo commit/flush/rollback handling. -- Better transient exception handling. \ No newline at end of file +Then, to run the API web server, here you type + +``` +pserve development.ini --reload +``` + +To process recurring transactions, here you can type + +``` +process_billy_tx development.ini +``` + +You can setup a crontab job to run the process_billy_tx periodically. + +## Running Unit and Functional Tests + +To run tests, after installing billy project and all dependencies, you need +to install dependencies for testing, here you type: + +``` +pip install -r test_requirements.txt +``` + +And to run the tests, here you type + +``` +python setup.py nosetests +``` + +or, if you prefer run specific tests, you can run + +``` +nosetests billy/tests/functional +``` + +## Running Integration Tests + +To run integration tests, here you type + +``` +nosetests billy/tests/integration +``` + +The default testing target URL is `http://127.0.0.1:6543`, to modify it, you can +set environment variable `BILLY_TEST_URL`. To change balanced API key, you can set +`BILLY_TEST_PROCESSOR_KEY` variable. For example + +``` +export BILLY_TEST_URL=http://example-billy-api.com +export BILLY_TEST_PROCESSOR_KEY=MY_SECRET_KEY_HERE +nosetests billy/tests/integration +``` diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..541336c --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,121 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# Vagrantfile API/syntax version. Don't touch unless you know what you're doing! +VAGRANTFILE_API_VERSION = "2" + +Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| + # All Vagrant configuration is done here. The most common configuration + # options are documented and commented below. For a complete reference, + # please see the online documentation at vagrantup.com. + + # Every Vagrant virtual environment requires a box to build off of. + config.vm.box = "precise64" + + # The url from where the 'config.vm.box' box will be fetched if it + # doesn't already exist on the user's system. + config.vm.box_url = "http://files.vagrantup.com/precise64.box" + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # config.vm.network :forwarded_port, guest: 80, host: 8080 + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + # config.vm.network :private_network, ip: "192.168.33.10" + + # Create a public network, which generally matched to bridged network. + # Bridged networks make the machine appear as another physical device on + # your network. + # config.vm.network :public_network + + # If true, then any SSH connections made will enable agent forwarding. + # Default value: false + # config.ssh.forward_agent = true + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + # config.vm.synced_folder "../data", "/vagrant_data" + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + # config.vm.provider :virtualbox do |vb| + # # Don't boot with headless mode + # vb.gui = true + # + # # Use VBoxManage to customize the VM. For example to change memory: + # vb.customize ["modifyvm", :id, "--memory", "1024"] + # end + # + # View the documentation for the provider you're using for more + # information on available options. + + # Enable provisioning with Puppet stand alone. Puppet manifests + # are contained in a directory path relative to this Vagrantfile. + # You will need to create the manifests directory and a manifest in + # the file precise64.pp in the manifests_path directory. + # + # An example Puppet manifest to provision the message of the day: + # + # # group { "puppet": + # # ensure => "present", + # # } + # # + # # File { owner => 0, group => 0, mode => 0644 } + # # + # # file { '/etc/motd': + # # content => "Welcome to your Vagrant-built virtual machine! + # # Managed by Puppet.\n" + # # } + # + # config.vm.provision :puppet do |puppet| + # puppet.manifests_path = "manifests" + # puppet.manifest_file = "init.pp" + # end + + # Enable provisioning with chef solo, specifying a cookbooks path, roles + # path, and data_bags path (all relative to this Vagrantfile), and adding + # some recipes and/or roles. + # + config.vm.provision :shell, :inline => "gem install chef --version 11.6.0 --no-rdoc --no-ri --conservative" + config.vm.provision :chef_solo do |chef| + chef.cookbooks_path = "chef/cookbooks" + chef.add_recipe "billy" + + chef.json = { + :postgresql => { + :password => { + :postgres => "billie jean" + } + } + } + end + + # Enable provisioning with chef server, specifying the chef server URL, + # and the path to the validation key (relative to this Vagrantfile). + # + # The Opscode Platform uses HTTPS. Substitute your organization for + # ORGNAME in the URL and validation key. + # + # If you have your own Chef Server, use the appropriate URL, which may be + # HTTP instead of HTTPS depending on your configuration. Also change the + # validation key to validation.pem. + # + # config.vm.provision :chef_client do |chef| + # chef.chef_server_url = "https://api.opscode.com/organizations/ORGNAME" + # chef.validation_key_path = "ORGNAME-validator.pem" + # end + # + # If you're using the Opscode platform, your validator client is + # ORGNAME-validator, replacing ORGNAME with your organization name. + # + # If you have your own Chef Server, the default validation client name is + # chef-validator, unless you changed the configuration. + # + # chef.validation_client_name = "ORGNAME-validator" +end diff --git a/billy/__init__.py b/billy/__init__.py index e69de29..35413c7 100644 --- a/billy/__init__.py +++ b/billy/__init__.py @@ -0,0 +1,27 @@ +from __future__ import unicode_literals + +from pyramid.config import Configurator + +from billy.models import setup_database +from billy.request import APIRequest + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + + """ + # setup database + settings = setup_database(global_config, **settings) + config = Configurator( + settings=settings, + request_factory=APIRequest, + ) + # add basic authentication parsing + config.add_tween('billy.api.auth.basic_auth_tween_factory') + # provides table entity to json renderers + config.include('.renderers') + # provides api views + config.include('.api') + + config.scan(ignore=b'billy.tests') + return config.make_wsgi_app() diff --git a/billy/api/__init__.py b/billy/api/__init__.py index eccc432..dd2d9c2 100644 --- a/billy/api/__init__.py +++ b/billy/api/__init__.py @@ -1,57 +1,9 @@ from __future__ import unicode_literals -import re -import difflib -from flask import request -from flask.ext.restful import Api -from flask.ext.restful.utils import unauthorized, error_data -from flask.signals import got_request_exception -from werkzeug.http import HTTP_STATUS_CODES -from werkzeug.exceptions import HTTPException - -class ApiFixed(Api): - - def handle_error(self, e): - """Error handler for the API transforms a raised exception into a Flask - response, with the appropriate HTTP status code and body. - - :param e: the raised Exception object - :type e: Exception - - """ - got_request_exception.send(self.app, exception=e) - if isinstance(e, HTTPException): - return e - code = getattr(e, 'code', 500) - data = getattr(e, 'data', error_data(code)) - - if code >= 500: - self.app.logger.exception("Internal Error") - - if code == 404 and ('message' not in data or - data['message'] == HTTP_STATUS_CODES[404]): - rules = dict([(re.sub('(<.*>)', '', rule.rule), rule.rule) - for rule in self.app.url_map.iter_rules()]) - close_matches = difflib.get_close_matches( - request.path, rules.keys()) - if close_matches: - # If we already have a message, add punctuation and - # continue it. - if "message" in data: - data["message"] += ". " - else: - data["message"] = "" - - data['message'] += 'You have requested this URI [' + request.path + \ - '] but did you mean ' + \ - ' or '.join((rules[match] - for match in close_matches)) + ' ?' - - resp = self.make_response(data, code) - - if code == 401: - resp = unauthorized(resp, - self.app.config.get("HTTP_BASIC_AUTH_REALM", "flask-restful")) - - return resp +def includeme(config): + config.include('.company', route_prefix='/v1') + config.include('.customer', route_prefix='/v1') + config.include('.plan', route_prefix='/v1') + config.include('.subscription', route_prefix='/v1') + config.include('.transaction', route_prefix='/v1') diff --git a/billy/api/app.py b/billy/api/app.py deleted file mode 100644 index f1ea7da..0000000 --- a/billy/api/app.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import unicode_literals - -from flask import Flask - -from billy.api.spec import billy_spec -from billy.api.resources.base import Home -from billy.api import ApiFixed - -app = Flask(__name__) -api = ApiFixed(app) - -api.add_resource(Home, '/') -# Register the resources using the spec -for resource, data in billy_spec.iteritems(): - api.add_resource(data['controller'], data['path']) diff --git a/billy/api/auth.py b/billy/api/auth.py new file mode 100644 index 0000000..5f698b1 --- /dev/null +++ b/billy/api/auth.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +import binascii + +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.company import CompanyModel + + +def auth_api_key(request): + """Authenticate API KEY and return corresponding company + + """ + model = CompanyModel(request.session) + company = model.get_by_api_key(unicode(request.remote_user)) + if company is None: + raise HTTPForbidden('Invalid API key {}'.format(request.remote_user)) + return company + + +def get_remote_user(request): + """Parse basic HTTP_AUTHORIZATION and return user name + + """ + if 'HTTP_AUTHORIZATION' not in request.environ: + return + authorization = request.environ['HTTP_AUTHORIZATION'] + try: + authmeth, auth = authorization.split(' ', 1) + except ValueError: # not enough values to unpack + return + if authmeth.lower() != 'basic': + return + try: + auth = auth.strip().decode('base64') + except binascii.Error: # can't decode + return + try: + login, password = auth.split(':', 1) + except ValueError: # not enough values to unpack + return + return login + + +def basic_auth_tween_factory(handler, registry): + """Do basic authentication, parse HTTP_AUTHORIZATION and set remote_user + variable to request + + """ + def basic_auth_tween(request): + remote_user = get_remote_user(request) + if remote_user is not None: + request.remote_user = remote_user + return handler(request) + return basic_auth_tween diff --git a/billy/api/company/__init__.py b/billy/api/company/__init__.py new file mode 100644 index 0000000..2c37bec --- /dev/null +++ b/billy/api/company/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('company', '/companies/{company_guid}') + config.add_route('company_list', '/companies/') diff --git a/billy/api/company/forms.py b/billy/api/company/forms.py new file mode 100644 index 0000000..37d01b8 --- /dev/null +++ b/billy/api/company/forms.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals + +from wtforms import Form +from wtforms import TextField +from wtforms import validators + + +class CompanyCreateForm(Form): + processor_key = TextField('Processor key', [ + validators.Required(), + ]) diff --git a/billy/api/company/views.py b/billy/api/company/views.py new file mode 100644 index 0000000..b283d4d --- /dev/null +++ b/billy/api/company/views.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +import transaction as db_transaction +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.company import CompanyModel +from billy.api.auth import auth_api_key +from billy.api.utils import validate_form +from .forms import CompanyCreateForm + + +@view_config(route_name='company_list', + request_method='GET', + renderer='json') +def company_list_get(request): + """Get and return the list of company + + """ + # TODO: + + +@view_config(route_name='company_list', + request_method='POST', + renderer='json') +def company_list_post(request): + """Create a new company + + """ + form = validate_form(CompanyCreateForm, request) + processor_key = form.data['processor_key'] + + model = CompanyModel(request.session) + # TODO: do validation here + with db_transaction.manager: + guid = model.create(processor_key=processor_key) + company = model.get(guid) + return company + + +@view_config(route_name='company', + request_method='GET', + renderer='json') +def company_get(request): + """Get and return a company + + """ + api_company = auth_api_key(request) + model = CompanyModel(request.session) + guid = request.matchdict['company_guid'] + company = model.get(guid) + if company is None: + return HTTPNotFound('No such company {}'.format(guid)) + if guid != api_company.guid: + return HTTPForbidden('You have no premission to access company {}'.format(guid)) + return company diff --git a/billy/api/customer/__init__.py b/billy/api/customer/__init__.py new file mode 100644 index 0000000..bb975cd --- /dev/null +++ b/billy/api/customer/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('customer', '/customers/{customer_guid}') + config.add_route('customer_list', '/customers/') diff --git a/billy/api/customer/forms.py b/billy/api/customer/forms.py new file mode 100644 index 0000000..5db964c --- /dev/null +++ b/billy/api/customer/forms.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals + +from wtforms import Form +from wtforms import TextField +from wtforms import validators + + +class CustomerCreateForm(Form): + external_id = TextField('External ID', [ + validators.Optional(), + ]) diff --git a/billy/api/customer/views.py b/billy/api/customer/views.py new file mode 100644 index 0000000..df8223f --- /dev/null +++ b/billy/api/customer/views.py @@ -0,0 +1,64 @@ +from __future__ import unicode_literals + +import transaction as db_transaction +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.customer import CustomerModel +from billy.api.auth import auth_api_key +from billy.api.utils import validate_form +from .forms import CustomerCreateForm + + +@view_config(route_name='customer_list', + request_method='GET', + renderer='json') +def customer_list_get(request): + """Get and return the list of customer + + """ + # TODO: + + +@view_config(route_name='customer_list', + request_method='POST', + renderer='json') +def customer_list_post(request): + """Create a new customer + + """ + company = auth_api_key(request) + form = validate_form(CustomerCreateForm, request) + + external_id = form.data.get('external_id') + company_guid = company.guid + + model = CustomerModel(request.session) + # TODO: do validation here + with db_transaction.manager: + guid = model.create( + external_id=external_id, + company_guid=company_guid, + ) + customer = model.get(guid) + return customer + + +@view_config(route_name='customer', + request_method='GET', + renderer='json') +def customer_get(request): + """Get and return a customer + + """ + company = auth_api_key(request) + model = CustomerModel(request.session) + guid = request.matchdict['customer_guid'] + customer = model.get(guid) + if customer is None: + return HTTPNotFound('No such customer {}'.format(guid)) + if customer.company_guid != company.guid: + return HTTPForbidden('You have no permission to access customer {}' + .format(guid)) + return customer diff --git a/billy/api/errors/__init__.py b/billy/api/errors/__init__.py deleted file mode 100644 index ef5c2e3..0000000 --- a/billy/api/errors/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from flask import jsonify, make_response -from werkzeug.exceptions import HTTPException - -from definitions import error_definitions - - -class FlaskErrorDict(dict): - bound_error = None - - def response(self): - if not self.bound_error: - raise ValueError('Must first bind an error.') - data = { - 'status': self.bound_error['status'], - 'error_code': self.bound_error['error_code'], - 'error_message': self.bound_error['error_message'], - 'server_time': datetime.utcnow() - } - resp = make_response((jsonify(data), self.bound_error['status'])) - return HTTPException(response=resp) - - def __getitem__(self, item): - error_body = super(FlaskErrorDict, self).__getitem__(item) - self.bound_error = error_body - return self.response() - - def __getattr__(self, item): - return self[item] - - -BillyExc = FlaskErrorDict(error_definitions) diff --git a/billy/api/errors/definitions.py b/billy/api/errors/definitions.py deleted file mode 100644 index ccca058..0000000 --- a/billy/api/errors/definitions.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import unicode_literals - -error_definitions = { - # GENERIC ERRORS - '400': { - 'status': 400, - 'error_message': 'Please check your request parameters.' - }, - - # GROUP ERRORS - '401': { - 'status': 401, - 'error_message': 'UnAuthorized: Invalid API Key' - }, - '405_DELETE_NON_TEST_GROUP': { - 'status': 405, - 'error_message': 'Cannot delete a non-test group.' - }, - - # CUSTOMER ERRORS - '404_CUSTOMER_NOT_FOUND': { - 'status': 404, - 'error_message': 'The customer you requested was not found.' - }, - '409_CUSTOMER_ALREADY_EXISTS': { - 'status': 409, - 'error_message': 'Cannot perform POST on an existing customer. Use ' - 'PUT instead.' - }, - - # COUPON ERRORS - '404_COUPON_NOT_FOUND': { - 'status': 404, - 'error_message': 'The coupon you requested was not found.' - }, - '409_COUPON_ALREADY_EXISTS': { - 'status': 409, - 'error_message': 'Cannot perform POST on an existing coupon. Use ' - 'PUT instead.' - }, - '409_COUPON_MAX_REDEEM': { - 'status': 409, - 'error_message': 'The coupon has already been redeemed maximum times' - }, - - - # PLAN ERRORS - '404_PLAN_NOT_FOUND': { - 'status': 404, - 'error_message': 'The plan you requested was not found.' - }, - '409_PLAN_ALREADY_EXISTS': { - 'status': 409, - 'error_message': 'Cannot perform POST on an existing plan. Use ' - 'PUT instead.' - }, - - # PayoutPlan ERRORS - '404_PAYOUT_NOT_FOUND': { - 'status': 404, - 'error_message': 'The payout you requested was not found.' - }, - '409_PAYOUT_ALREADY_EXISTS': { - 'status': 409, - 'error_message': 'Cannot perform POST on an existing payout. Use ' - 'PUT instead.' - }, - - # ChargePlan Subscription Errors - '404_PLAN_SUB_NOT_FOUND': { - 'status': 404, - 'error_message': 'The plan subscription you requested was not found.' - }, - - # PayoutPlan Subscription Errors - '404_PAYOUT_SUB_NOT_FOUND': { - 'status': 404, - 'error_message': 'The payout subscription you requested was not found.' - }, - - # ChargePlan Invoice Errors - '404_PLAN_INV_NOT_FOUND': { - 'status': 404, - 'error_message': 'The plan invoice you requested was not found.' - }, - - # PayoutPlan Invoice Errors - '404_PAYOUT_INV_NOT_FOUND': { - 'status': 404, - 'error_message': 'The payout invoice you requested was not found.' - }, - - - # ChargePlan Invoice Errors - '404_PLAN_TRANS_NOT_FOUND': { - 'status': 404, - 'error_message': 'The plan transaction you requested was not found.' - }, - - # PayoutPlan Invoice Errors - '404_PAYOUT_TRANS_NOT_FOUND': { - 'status': 404, - 'error_message': 'The payout transaction you requested was not found.' - }, - - # FIELD ERRORS - # Todo Temp place holders until validators are fed into the error_messages - '400_CUSTOMER_ID': { - 'status': 400, - 'error_message': 'Invalid customer_id. Please check.' - }, - '400_PROVIDER_ID': { - 'status': 400, - 'error_message': 'Invalid provider_id. Please check.' - }, - '400_COUPON_ID': { - 'status': 400, - 'error_message': 'Invalid coupon_id. Please check.' - }, - '400_PLAN_ID': { - 'status': 400, - 'error_message': 'Invalid coupon_id. Please check.' - }, - '400_PAYOUT_ID': { - 'status': 400, - 'error_message': 'Invalid coupon_id. Please check.' - }, - '400_NAME': { - 'status': 400, - 'error_message': 'Invalid name. Please check.' - }, - '400_MAX_REDEEM': { - 'status': 400, - 'error_message': 'Invalid max_redeem. Please check.' - }, - '400_REPEATING': { - 'status': 400, - 'error_message': 'Invalid repeating. Please check.' - }, - '400_EXPIRE_AT': { - 'status': 400, - 'error_message': 'Invalid expire_at. Please check.' - }, - '400_PERCENT_OFF_INT': { - 'status': 400, - 'error_message': 'Invalid percent_off_int. Please check.' - }, - '400_PRICE_OFF_CENTS': { - 'status': 400, - 'error_message': 'Invalid price_off_cents. Please check.' - }, - '400_PRICE_CENTS': { - 'status': 400, - 'error_message': 'Invalid price_cents. Please check.' - }, - '400_TRIAL_INTERVAL': { - 'status': 400, - 'error_message': 'Invalid trial_interval. Please check.' - }, - '400_PLAN_INTERVAL': { - 'status': 400, - 'error_message': 'Invalid plan_interval. Please check.' - }, - '400_PAYOUT_INTERVAL': { - 'status': 400, - 'error_message': 'Invalid payout_interval. Please check.' - }, - '400_BALANCE_TO_KEEP_CENTS': { - 'status': 400, - 'error_message': 'Invalid balance_to_keep_cents. Please check.' - }, - '400_QUANTITY': { - 'status': 400, - 'error_message': 'Invalid quantity. Please check.' - }, - -} - -for key in error_definitions.keys(): - error_definitions[key]['error_code'] = key diff --git a/billy/api/plan/__init__.py b/billy/api/plan/__init__.py new file mode 100644 index 0000000..322a531 --- /dev/null +++ b/billy/api/plan/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('plan', '/plans/{plan_guid}') + config.add_route('plan_list', '/plans/') diff --git a/billy/api/plan/forms.py b/billy/api/plan/forms.py new file mode 100644 index 0000000..cff607c --- /dev/null +++ b/billy/api/plan/forms.py @@ -0,0 +1,45 @@ +from __future__ import unicode_literals + +from wtforms import Form +from wtforms import RadioField +from wtforms import IntegerField +from wtforms import DecimalField +from wtforms import validators + + +class PlanCreateForm(Form): + plan_type = RadioField( + 'Plan type', + [ + validators.Required(), + ], + choices=[ + ('charge', 'Charge'), + ('payout', 'Payout'), + ] + ) + frequency = RadioField( + 'Frequency', + [ + validators.Required(), + ], + choices=[ + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('monthly', 'Monthly'), + ('yearly', 'Yearly'), + ] + ) + amount = DecimalField('Amount', [ + validators.Required(), + # TODO: what is the minimum amount limitation we have? + validators.NumberRange(min=0.01) + ]) + interval = IntegerField( + 'Interval', + [ + validators.Optional(), + validators.NumberRange(min=1), + ], + default=1 + ) diff --git a/billy/api/plan/views.py b/billy/api/plan/views.py new file mode 100644 index 0000000..6d7f7d3 --- /dev/null +++ b/billy/api/plan/views.py @@ -0,0 +1,74 @@ +from __future__ import unicode_literals + +import transaction as db_transaction +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.plan import PlanModel +from billy.api.auth import auth_api_key +from billy.api.utils import validate_form +from .forms import PlanCreateForm + + +@view_config(route_name='plan_list', + request_method='POST', + renderer='json') +def plan_list_post(request): + """Create a new plan + + """ + company = auth_api_key(request) + form = validate_form(PlanCreateForm, request) + + plan_type = form.data['plan_type'] + amount = form.data['amount'] + frequency = form.data['frequency'] + interval = form.data['interval'] + if interval is None: + interval = 1 + company_guid = company.guid + + model = PlanModel(request.session) + type_map = dict( + charge=model.TYPE_CHARGE, + payout=model.TYPE_PAYOUT, + ) + plan_type = type_map[plan_type] + freq_map = dict( + daily=model.FREQ_DAILY, + weekly=model.FREQ_WEEKLY, + monthly=model.FREQ_MONTHLY, + yearly=model.FREQ_YEARLY, + ) + frequency = freq_map[frequency] + + with db_transaction.manager: + guid = model.create( + company_guid=company_guid, + plan_type=plan_type, + amount=amount, + frequency=frequency, + interval=interval, + ) + plan = model.get(guid) + return plan + + +@view_config(route_name='plan', + request_method='GET', + renderer='json') +def plan_get(request): + """Get and return a plan + + """ + company = auth_api_key(request) + model = PlanModel(request.session) + guid = request.matchdict['plan_guid'] + plan = model.get(guid) + if plan is None: + return HTTPNotFound('No such plan {}'.format(guid)) + if plan.company_guid != company.guid: + return HTTPForbidden('You have no permission to access plan {}' + .format(guid)) + return plan diff --git a/billy/api/resources/__init__.py b/billy/api/resources/__init__.py deleted file mode 100644 index b75dbb3..0000000 --- a/billy/api/resources/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generic -from .base import Base - -#Fluff -from billy.api.resources.base import Home - -#API RESOURCES -from billy.api.resources.group import GroupController -from billy.api.resources.customer import (CustomerIndexController, - CustomerController, customer_view, - CustomerCreateForm, CustomerUpdateForm) -from billy.api.resources.coupon import (CouponIndexController, CouponController, - coupon_view, CouponCreateForm, - CouponUpdateForm) -from billy.api.resources.plan import (PlanIndexController, PlanController, plan_view, PlanCreateForm, PlanUpdateForm) -from billy.api.resources.payout import (PayoutIndexController, PayoutController, - payout_view, PayoutCreateForm, PayoutUpdateForm) -from billy.api.resources.plan_subscription import (PlanSubIndexController, - PlanSubController, plan_sub_view, PlanSubCreateForm, PlanSubDeleteForm) -from billy.api.resources.payout_subscription import (PayoutSubIndexController, - PayoutSubController, - payout_sub_view, PayoutSubCreateForm, PayoutSubDeleteForm) -from billy.api.resources.plan_invoice import (PlanInvController, - PlanInvIndexController, plan_inv_view) -from billy.api.resources.payout_invoice import (PayoutInvController, - PayoutInvIndexController, - payout_inv_view) -from billy.api.resources.plan_transaction import (PlanTransIndexController, - PlanTransController, - plan_trans_view) -from billy.api.resources.payout_transaction import (PayoutTransIndexController, - PayoutTransController, - payout_trans_view) diff --git a/billy/api/resources/base/__init__.py b/billy/api/resources/base/__init__.py deleted file mode 100644 index d03cbb1..0000000 --- a/billy/api/resources/base/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import unicode_literals - -from flask import request, Response -from flask.ext import restful - -from billy.api.errors import BillyExc - - -class Base(restful.Resource): - - """ - Base view class to do what you want with - """ - - def api_key_from_request(self): - auth = request.authorization - api_key = auth.get('password') if auth else \ - request.headers.get('Authorization') - api_key = api_key or request.form.get('api_key') or \ - request.args.get('api_key') - return api_key - - def param_from_request(self, param): - return request.view_args.get(param) or \ - request.args.get(param) or request.form.get(param) - - def dispatch_request(self, *args, **kwargs): - # Taken from flask - # noinspection PyUnresolvedReferences - meth = getattr(self, request.method.lower(), None) - if meth is None and request.method == 'HEAD': - meth = getattr(self, 'get', None) - assert meth is not None, 'Unimplemented method %r' % request.method - - for decorator in self.method_decorators: - meth = decorator(meth) - - resp = meth(*args, **kwargs) - - if isinstance(resp, Response): # There may be a better way to test - return resp - - representations = self.representations or {} - - # noinspection PyUnresolvedReferences - for mediatype in self.mediatypes(): - if mediatype in representations: - data, code, headers = unpack(resp) - resp = representations[mediatype](data, code, headers) - resp.headers['Content-Type'] = mediatype - return resp - - return resp - - def form_error(self, errors): - last_key = None - for key, value in errors.iteritems(): - last_key = key - exc_key = '400_{}'.format(key.upper()) - if BillyExc.get(exc_key): - raise BillyExc[exc_key] - raise Exception('Field error for {} not defined!'.format(last_key)) - - -class Home(Base): - - def get(self): - return { - "Welcome to billy": - "Checkout here {}".format( - 'https://www.github.com/balanced/billy') - } diff --git a/billy/api/resources/coupon/__init__.py b/billy/api/resources/coupon/__init__.py deleted file mode 100644 index 25caed3..0000000 --- a/billy/api/resources/coupon/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Coupon -from .form import CouponCreateForm, CouponUpdateForm -from .view import coupon_view - - -class CouponIndexController(GroupController): - - """ - Base coupon resource used to create a coupon or retrieve all your - coupons - """ - - @marshal_with(coupon_view) - def get(self): - """ - Return a list of coupon pertaining to a group - """ - return self.group.coupons - - @marshal_with(coupon_view) - def post(self): - """ - Create a coupon - """ - coupon_form = CouponCreateForm(request.form) - if coupon_form.validate(): - return coupon_form.save(self.group) - else: - self.form_error(coupon_form.errors) - - -class CouponController(GroupController): - - """ - Methods pertaining to a single coupon - """ - - def __init__(self): - super(CouponController, self).__init__() - coupon_id = request.view_args.values()[0] - self.coupon = Coupon.retrieve(coupon_id, self.group.id) - if not self.coupon: - raise BillyExc['404_COUPON_NOT_FOUND'] - - @marshal_with(coupon_view) - def get(self, coupon_id): - """ - Retrieve a single coupon - """ - return self.coupon - - @marshal_with(coupon_view) - def put(self, coupon_id): - """ - Update a customer, currently limited to updating their coupon. - """ - coupon_form = CouponUpdateForm(request.form) - if coupon_form.validate(): - return coupon_form.save(self.coupon) - else: - self.form_error(coupon_form.errors) - - def delete(self, coupon_id): - """ - Deletes a coupon by marking it inactive. Does not effect users already - on the coupon. - """ - self.coupon.delete() - return None diff --git a/billy/api/resources/coupon/form.py b/billy/api/resources/coupon/form.py deleted file mode 100644 index 64503ef..0000000 --- a/billy/api/resources/coupon/form.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.exc import * -from wtforms import ( - Form, TextField, IntegerField, validators, DateTimeField) - -from billy.models import Coupon -from billy.api.errors import BillyExc - - -class CouponCreateForm(Form): - coupon_id = TextField('Coupon ID', - [validators.Required(), - validators.Length(min=5, max=150)]) - name = TextField('Name', - [validators.Required(), - validators.Length(min=3, max=150)]) - - price_off_cents = IntegerField('Price off cents') - - percent_off_int = IntegerField('Percent off') - - max_redeem = IntegerField('Max Redemptions') - - repeating = IntegerField('Repeating') - - expire_at = DateTimeField('Expire at', default=None) - - - def validate_max_redeem(self, key, address): - if not (address > 0 or address == -1): - raise ValueError('400_MAX_REDEEM') - return address - - def validate_repeating(self, key, address): - if not (address > 0 or address == -1): - raise ValueError('400_REPEATING') - return address - - def validate_percent_off_int(self, key, address): - if not 0 <= address <= 100: - raise ValueError('400_PERCENT_OFF_INT') - return address - - def validate_price_off_cents(self, key, address): - if not address >= 0: - raise ValueError('400_PRICE_OFF_CENTS') - return address - - def save(self, group_obj): - try: - coupon = Coupon.create(your_id=self.coupon_id.data, - group_id=group_obj.id, - name=self.name.data, - price_off_cents=self.price_off_cents.data, - percent_off_int=self.percent_off_int.data, - max_redeem=self.max_redeem.data, - repeating=self.repeating.data, - ) - return coupon - except IntegrityError: - raise BillyExc['409_COUPON_ALREADY_EXISTS'] - except ValueError, e: - raise BillyExc[e.message] # ERROR code passed by model. - - -class CouponUpdateForm(Form): - name = TextField('Name', - [validators.Length(min=3, max=150)], default=None) - - max_redeem = IntegerField('Max Redemptions', default=None) - - repeating = IntegerField('Repeating', default=None) - - expire_at = DateTimeField('Expire at', default=None) - - def save(self, coupon): - try: - return coupon.update(new_name=self.name.data, - new_max_redeem=self.max_redeem.data, - new_expire_at=self.expire_at.data, - new_repeating=self.repeating.data) - except ValueError, e: - raise BillyExc[e.message] diff --git a/billy/api/resources/coupon/view.py b/billy/api/resources/coupon/view.py deleted file mode 100644 index 9b52122..0000000 --- a/billy/api/resources/coupon/view.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -coupon_view = { - # Todo: figure out why coupon_id isnt showing... - 'coupon_id': fields.String(attribute='your_id'), - 'created_at': fields.DateTime(), - 'name': fields.String(), - 'expire_at': fields.DateTime(), - 'price_off_cents': fields.Integer(), - 'percent_off_int': fields.Integer(), - 'max_redeem': fields.Integer(), - 'repeating': fields.Integer(), - 'active': fields.Boolean(), - -} diff --git a/billy/api/resources/customer/__init__.py b/billy/api/resources/customer/__init__.py deleted file mode 100644 index 0b27495..0000000 --- a/billy/api/resources/customer/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Customer -from .form import CustomerCreateForm, CustomerUpdateForm -from .view import customer_view - - -class CustomerIndexController(GroupController): - - """ - Base customer resource used to create a customer or retrieve all your - customers - """ - - @marshal_with(customer_view) - def get(self): - """ - Return a list of customers pertaining to a group - """ - return self.group.customers - - @marshal_with(customer_view) - def post(self): - """ - Create a customer - """ - customer_form = CustomerCreateForm(request.form) - if customer_form.validate(): - return customer_form.save(self.group), 201 - else: - self.form_error(customer_form.errors) - - -class CustomerController(GroupController): - - """ - Methods pertaining to a single customer - """ - - def __init__(self): - super(CustomerController, self).__init__() - customer_id = request.view_args.values()[0] - self.customer = Customer.retrieve(customer_id, self.group.id) - if not self.customer: - raise BillyExc['404_CUSTOMER_NOT_FOUND'] - - @marshal_with(customer_view) - def get(self, customer_id): - """ - Retrieve a single customer - """ - return self.customer - - @marshal_with(customer_view) - def put(self, customer_id): - """ - Update a customer, currently limited to updating their coupon. - """ - customer_form = CustomerUpdateForm(request.form) - if customer_form.validate(): - return customer_form.save(self.customer) - else: - self.form_error(customer_form.errors) diff --git a/billy/api/resources/customer/form.py b/billy/api/resources/customer/form.py deleted file mode 100644 index 0ecc386..0000000 --- a/billy/api/resources/customer/form.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.exc import * -from wtforms import Form, TextField, validators - -from billy.models import Customer -from billy.api.errors import BillyExc - - -class CustomerCreateForm(Form): - customer_id = TextField('Customer ID', - [validators.Required(), - validators.Length(min=5, max=150)]) - provider_id = TextField('Balanced ID', - [validators.Required(), - validators.Length(min=5, max=150)]) - - def save(self, group_obj): - try: - customer = Customer.create(your_id=self.customer_id.data, - group_id=group_obj.id, - provider_id=self.provider_id.data) - return customer - except IntegrityError: - raise BillyExc['409_CUSTOMER_ALREADY_EXISTS'] - - -class CustomerUpdateForm(Form): - coupon_id = TextField('Coupon ID') - - def save(self, customer): - try: - return customer.apply_coupon(self.coupon_id.data) - except ValueError: - raise BillyExc['409_COUPON_MAX_REDEEM'] - except NameError: - raise BillyExc['404_COUPON_NOT_FOUND'] diff --git a/billy/api/resources/customer/view.py b/billy/api/resources/customer/view.py deleted file mode 100644 index 21f61e2..0000000 --- a/billy/api/resources/customer/view.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -customer_view = { - # Todo: figure out why some attributes arent showing... - 'id': fields.String(attribute='your_id'), - 'created_at': fields.DateTime(), - 'provider_id': fields.String(), - 'last_debt_clear': fields.DateTime(), - 'charge_attempts': fields.Integer(), - 'current_coupon': fields.String(attribute='coupon.your_id') -} diff --git a/billy/api/resources/group/__init__.py b/billy/api/resources/group/__init__.py deleted file mode 100644 index 14098b0..0000000 --- a/billy/api/resources/group/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import unicode_literals - -from billy.api.resources import Base -from billy.api.errors import BillyExc -from billy.models import Company -from billy.settings import TEST_API_KEYS - - -class GroupController(Base): - """ - Base authentication route that converts an API key to a group - """ - api_key = None - group = None - - def __init__(self): - super(GroupController, self).__init__() - self.api_key = self.api_key_from_request() - self.group = self.pull_group_object() - - def pull_group_object(self): - if not self.api_key: - raise BillyExc['401'] - result = self.get_group_from_api_key(self.api_key) - if not result: - raise BillyExc['401'] - return result - - def get_group_from_api_key(self, api_key): - """ - Takes an API key and grabs the Company associated with it. - If the test API key is used and the test group doesnt exists it creates - one and returns it. - :param api_key: The API key - :return: - """ - result = Company.query.filter(Company.api_key == api_key).first() - if not result and api_key in TEST_API_KEYS: - return Company.create( - 'MY_TEST_GROUP_{}'.format(TEST_API_KEYS.index(api_key)), - processor_type='DUMMY', processor_credential='SOME_API_KEY', - api_key=api_key) - return result - - def get(self): - """ - Used to test api_key and authentication - """ - resp = { - 'AUTH_SUCCESS': True, - 'GROUP_ID': '{}'.format(self.group.your_id) - } - return resp - - - def delete(self): - """ - Deletes a group. ONLY deletion of test groups allowed. - """ - if self.group.is_test: - self.group.delete() - else: - raise BillyExc['405_DELETE_NON_TEST_GROUP'] - diff --git a/billy/api/resources/payout/__init__.py b/billy/api/resources/payout/__init__.py deleted file mode 100644 index 4398f32..0000000 --- a/billy/api/resources/payout/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import PayoutPlan -from .form import PayoutCreateForm, PayoutUpdateForm -from .view import payout_view - - -class PayoutIndexController(GroupController): - - """ - Base PayoutPlan resource used to create a payout or retrieve all your - payouts - """ - - @marshal_with(payout_view) - def get(self): - """ - Return a list of payouts pertaining to a group - """ - return self.group.payouts.all() - - @marshal_with(payout_view) - def post(self): - """ - Create a payout - """ - payout_form = PayoutCreateForm(request.form) - if payout_form.validate(): - return payout_form.save(self.group) - else: - self.form_error(payout_form.errors) - - -class PayoutController(GroupController): - - """ - Methods pertaining to a single payout - """ - - def __init__(self): - super(PayoutController, self).__init__() - payout_id = request.view_args.values()[0] - self.payout = PayoutPlan.retrieve(payout_id, self.group.id) - if not self.payout: - raise BillyExc['404_PAYOUT_NOT_FOUND'] - - @marshal_with(payout_view) - def get(self, payout_id): - """ - Retrieve a single payout - """ - return self.payout - - @marshal_with(payout_view) - def put(self, payout_id): - """ - Update the name of a payout - """ - payout_form = PayoutUpdateForm(request.form) - if payout_form.validate(): - return payout_form.save(self.payout) - else: - self.form_error(payout_form.errors) - - def delete(self, payout_id): - """ - Deletes a payout by marking it inactive. Does not effect users already - on the payout. - """ - self.payout.delete() - return None diff --git a/billy/api/resources/payout/form.py b/billy/api/resources/payout/form.py deleted file mode 100644 index 2cfe3d4..0000000 --- a/billy/api/resources/payout/form.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.exc import * -from wtforms import (Form, TextField, IntegerField, validators) - -from billy.api.errors import BillyExc -from billy.models import PayoutPlan -from billy.utils.intervals import interval_matcher - - -class PayoutCreateForm(Form): - payout_id = TextField('PayoutPlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - name = TextField('Name', - [validators.Required(), - validators.Length(min=3, max=150)]) - - balance_to_keep_cents = IntegerField('Balance to Keep Cents', - [validators.Required()]) - - payout_interval = TextField('PayoutPlan Interval', [validators.Required()]) - - def validate_balance_to_keep(self, key, address): - if not address > 0: - raise ValueError("400_BALANCE_TO_KEEP_CENTS") - return address - - - def save(self, group_obj): - try: - try: - payout_int = interval_matcher(self.payout_interval.data) - except ValueError: - raise BillyExc['400_PAYOUT_INTERVAL'] - return PayoutPlan.create(your_id=self.payout_id.data, - group_id=group_obj.id, - name=self.name.data, - balance_to_keep_cents=self - .balance_to_keep_cents.data, - payout_interval=payout_int, - ) - except IntegrityError: - raise BillyExc['409_PAYOUT_ALREADY_EXISTS'] - except ValueError, e: - raise BillyExc[e.message] - - -class PayoutUpdateForm(Form): - name = TextField('Name', [validators.Length(min=3, max=150)], default=None) - - def save(self, payout): - if self.name: - return payout.update(self.name.data) - return payout diff --git a/billy/api/resources/payout/view.py b/billy/api/resources/payout/view.py deleted file mode 100644 index dcf0047..0000000 --- a/billy/api/resources/payout/view.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields -from billy.utils.intervals import IntervalViewField - -payout_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='your_id'), - 'created_at': fields.DateTime(), - 'name': fields.String(), - 'balance_to_keep_cents': fields.Integer(), - 'active': fields.Boolean(), - 'payout_interval': IntervalViewField(), -} diff --git a/billy/api/resources/payout_invoice/__init__.py b/billy/api/resources/payout_invoice/__init__.py deleted file mode 100644 index 6764fa4..0000000 --- a/billy/api/resources/payout_invoice/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Company, Customer, PayoutPlanInvoice, PayoutSubscription -from .view import payout_inv_view - - -class PayoutInvIndexController(GroupController): - """ - Base PayoutPlanInvoice resource used to create a payout invoice or - retrieve all your payout invoices - """ - - @marshal_with(payout_inv_view) - def get(self): - """ - Return a list of payout invoices pertaining to a group - """ - return PayoutPlanInvoice.query.join(PayoutSubscription).join(Customer).join( - Company).filter(Company.id == self.group.id).all() - - -class PayoutInvController(GroupController): - """ - Methods pertaining to a single payout invoice - """ - - def __init__(self): - super(PayoutInvController, self).__init__() - payout_inv_id = request.view_args.values()[0] - self.invoice = PayoutPlanInvoice.query.filter( - PayoutPlanInvoice.id == payout_inv_id).first() - if not self.invoice: - raise BillyExc['404_PAYOUT_INV_NOT_FOUND'] - - @marshal_with(payout_inv_view) - def get(self, payout_inv_id): - """ - Retrieve a single invoice - """ - return self.invoice diff --git a/billy/api/resources/payout_invoice/view.py b/billy/api/resources/payout_invoice/view.py deleted file mode 100644 index 2baeb37..0000000 --- a/billy/api/resources/payout_invoice/view.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -payout_inv_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'payout_id': fields.String(attribute='subscription.payout.your_id'), - 'customer_id': fields.String(attribute='subscription.customer.your_id'), - 'subscription_id': fields.String(attribute='subscription.id'), - 'payout_dt': fields.DateTime(), - 'balance_at_exec': fields.Integer(), - 'amount_paid_out': fields.Integer(), - 'attempts_made': fields.Integer(), - 'cleared_by_txn': fields.String(), -} diff --git a/billy/api/resources/payout_subscription/__init__.py b/billy/api/resources/payout_subscription/__init__.py deleted file mode 100644 index 148f7ba..0000000 --- a/billy/api/resources/payout_subscription/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Customer, Company, PayoutSubscription -from .form import PayoutSubCreateForm, PayoutSubDeleteForm -from .view import payout_sub_view - - -class PayoutSubIndexController(GroupController): - """ - Base PayoutSubscription resource used to create a payout subscription or - retrieve all your payout subscriptions - """ - - @marshal_with(payout_sub_view) - def get(self): - """ - Return a list of payout subscriptions pertaining to a group - """ - return PayoutSubscription.query.join(Customer).join(Company).filter( - Company.id == self.group.id).all() - - @marshal_with(payout_sub_view) - def post(self): - """ - Create or update a payout subscription - """ - sub_form = PayoutSubCreateForm(request.form) - if sub_form.validate(): - return sub_form.save(self.group) - else: - self.form_error(sub_form.errors) - - @marshal_with(payout_sub_view) - def delete(self): - """ - Unsubscribe from the payout - """ - sub_form = PayoutSubDeleteForm(request.form) - if sub_form.validate(): - return sub_form.save(self.group) - else: - self.form_error(sub_form.errors) - - -class PayoutSubController(GroupController): - """ - Methods pertaining to a single payout subscription - """ - - def __init__(self): - super(PayoutSubController, self).__init__() - payout_sub_id = request.view_args.values()[0] - self.subscription = PayoutSubscription.query.filter( - PayoutSubscription.id == payout_sub_id).first() - if not self.subscription: - raise BillyExc['404_PAYOUT_SUB_NOT_FOUND'] - - @marshal_with(payout_sub_view) - def get(self, payout_sub_id): - """ - Retrieve a single subscription - """ - return self.subscription diff --git a/billy/api/resources/payout_subscription/form.py b/billy/api/resources/payout_subscription/form.py deleted file mode 100644 index 707ca30..0000000 --- a/billy/api/resources/payout_subscription/form.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.orm.exc import * -from wtforms import ( - Form, TextField, validators, DateTimeField, BooleanField) - -from billy.api.errors import BillyExc -from billy.models import Customer, PayoutPlan, PayoutSubscription - - -class PayoutSubCreateForm(Form): - customer_id = TextField('Customer ID', [validators.Required(), - validators.Length(min=5, max=150)]) - payout_id = TextField('PayoutPlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - - first_now = BooleanField('Charge at period end?', default=False) - - start_dt = DateTimeField('Start Datetime', default=None) - - def save(self, group_obj): - try: - customer = Customer.retrieve(self.customer_id.data, group_obj.id) - if not customer: - raise BillyExc['404_CUSTOMER_NOT_FOUND'] - payout = PayoutPlan.retrieve(self.payout_id.data, group_obj.id) - if not payout: - raise BillyExc['404_PAYOUT_NOT_FOUND'] - return PayoutSubscription.subscribe(customer, payout, - first_now=self.first_now.data, - start_dt=self.start_dt.data)\ - .subscription - except ValueError, e: - raise BillyExc[e.message] - - -class PayoutSubDeleteForm(Form): - customer_id = TextField('Customer ID', [validators.Required(), - validators.Length(min=5, max=150)]) - payout_id = TextField('PayoutPlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - - cancel_scheduled = BooleanField('Cancel scheduled?', default=False) - - def save(self, group_obj): - try: - customer = Customer.retrieve(self.customer_id.data, group_obj.id) - if not customer: - raise BillyExc['404_CUSTOMER_NOT_FOUND'] - payout = PayoutPlan.retrieve(self.payout_id.data, group_obj.id) - if not payout: - raise BillyExc['404_PLAN_NOT_FOUND'] - return PayoutSubscription.unsubscribe(customer, payout, - cancel_scheduled=self - .cancel_scheduled.data)\ - .subscription - except NoResultFound: - raise BillyExc['404_PLAN_SUB_NOT_FOUND'] - except ValueError, e: - raise BillyExc[e.message] diff --git a/billy/api/resources/payout_subscription/view.py b/billy/api/resources/payout_subscription/view.py deleted file mode 100644 index 80542f9..0000000 --- a/billy/api/resources/payout_subscription/view.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -payout_sub_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'payout_id': fields.String(attribute='payout.your_id'), - 'customer_id': fields.String(attribute='customer.your_id'), - 'is_active': fields.Boolean(), - # Todo add invoices field -} diff --git a/billy/api/resources/payout_transaction/__init__.py b/billy/api/resources/payout_transaction/__init__.py deleted file mode 100644 index 0fa422e..0000000 --- a/billy/api/resources/payout_transaction/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Company, Customer, PayoutTransaction -from .view import payout_trans_view - - -class PayoutTransIndexController(GroupController): - """ - Base PayoutPlan Transaction resource used to create a payout transaction or - retrieve all your payout transactions - """ - - @marshal_with(payout_trans_view) - def get(self): - """ - Return a list of payout transactions pertaining to a group - """ - return PayoutTransaction.query.join(Customer).join( - Company).filter(Company.id == self.group.id).all() - - -class PayoutTransController(GroupController): - """ - Methods pertaining to a single payout transaction - """ - - def __init__(self): - super(PayoutTransController, self).__init__() - payout_trans_id = request.view_args.values()[0] - self.trans = PayoutTransaction.query.filter( - PayoutTransaction.id == payout_trans_id).first() - if not self.trans: - raise BillyExc['404_PAYOUT_TRANS_NOT_FOUND'] - - @marshal_with(payout_trans_view) - def get(self, payout_trans_id): - """ - Retrieve a single transaction - """ - return self.trans diff --git a/billy/api/resources/payout_transaction/view.py b/billy/api/resources/payout_transaction/view.py deleted file mode 100644 index 34e0fe7..0000000 --- a/billy/api/resources/payout_transaction/view.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -payout_trans_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'invoices': fields.String(attribute='payout_invoices'), - 'customer_id': fields.String(), - 'amount_cents': fields.Integer(), - 'status': fields.String(), - 'provider_txn_id': fields.String(), -} diff --git a/billy/api/resources/plan/__init__.py b/billy/api/resources/plan/__init__.py deleted file mode 100644 index 62ca582..0000000 --- a/billy/api/resources/plan/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import ChargePlan -from .form import PlanCreateForm, PlanUpdateForm -from .view import plan_view - - -class PlanIndexController(GroupController): - - """ - Base ChargePlan resource used to create a plan or retrieve all your - plans - """ - - @marshal_with(plan_view) - def get(self): - """ - Return a list of plans pertaining to a group - """ - return self.group.plans.all() - - @marshal_with(plan_view) - def post(self): - """ - Create a plan - """ - plan_form = PlanCreateForm(request.form) - if plan_form.validate(): - return plan_form.save(self.group) - else: - self.form_error(plan_form.errors) - - -class PlanController(GroupController): - - """ - Methods pertaining to a single plan - """ - - def __init__(self): - super(PlanController, self).__init__() - plan_id = request.view_args.values()[0] - self.plan = ChargePlan.retrieve(plan_id, self.group.id) - if not self.plan: - raise BillyExc['404_PLAN_NOT_FOUND'] - - @marshal_with(plan_view) - def get(self, plan_id): - """ - Retrieve a single plan - """ - return self.plan - - @marshal_with(plan_view) - def put(self, plan_id): - """ - Update the name of a plan - """ - plan_form = PlanUpdateForm(request.form) - if plan_form.validate(): - return plan_form.save(self.plan) - else: - self.form_error(plan_form.errors) - - def delete(self, plan_id): - """ - Deletes a plan by marking it inactive. Does not effect users already - on the plan. - """ - self.plan.delete() - return None diff --git a/billy/api/resources/plan/form.py b/billy/api/resources/plan/form.py deleted file mode 100644 index f816569..0000000 --- a/billy/api/resources/plan/form.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.exc import * -from wtforms import (Form, TextField, IntegerField, validators) - -from billy.api.errors import BillyExc -from billy.models import ChargePlan -from billy.utils.intervals import interval_matcher - - -class PlanCreateForm(Form): - plan_id = TextField('ChargePlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - name = TextField('Name', - [validators.Required(), - validators.Length(min=3, max=150)]) - - price_cents = IntegerField('Price Cents', [validators.Required()]) - - plan_interval = TextField('ChargePlan Interval', [validators.Required()]) - - trial_interval = TextField('Trial Interval', default=None) - - def validate_price_cents(self, key, address): - if not address > 0: - raise ValueError("400_PRICE_CENTS") - return address - - def save(self, group_obj): - try: - try: - if self.trial_interval.data: - trial_int = interval_matcher(self.trial_interval.data) - else: - trial_int = None - except ValueError: - raise BillyExc['400_TRIAL_INTERVAL'] - try: - plan_int = interval_matcher(self.plan_interval.data) - except ValueError: - raise BillyExc['400_PLAN_INTERVAL'] - return ChargePlan.create(your_id=self.plan_id.data, - group_id=group_obj.id, - name=self.name.data, - price_cents=self.price_cents.data, - plan_interval=plan_int, - trial_interval=trial_int, - ) - except IntegrityError: - raise BillyExc['409_PLAN_ALREADY_EXISTS'] - except ValueError, e: - raise BillyExc[e.message] - - -class PlanUpdateForm(Form): - name = TextField('Name', [validators.Length(min=3, max=150)], default=None) - - def save(self, plan): - if self.name: - return plan.update(self.name.data) - return plan diff --git a/billy/api/resources/plan/view.py b/billy/api/resources/plan/view.py deleted file mode 100644 index 813e0da..0000000 --- a/billy/api/resources/plan/view.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields -from billy.utils.intervals import IntervalViewField - -plan_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='your_id'), - 'created_at': fields.DateTime(), - 'name': fields.String(), - 'price_cents': fields.Integer(), - 'active': fields.Boolean(), - 'plan_interval': IntervalViewField(), - 'trial_interval': IntervalViewField(), - -} diff --git a/billy/api/resources/plan_invoice/__init__.py b/billy/api/resources/plan_invoice/__init__.py deleted file mode 100644 index 0646e8d..0000000 --- a/billy/api/resources/plan_invoice/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Company, Customer, ChargePlanInvoice, ChargeSubscription -from .view import plan_inv_view - - -class PlanInvIndexController(GroupController): - """ - Base ChargePlanInvoice resource used to create a plan invoice or - retrieve all your plan invoices - """ - - @marshal_with(plan_inv_view) - def get(self): - """ - Return a list of plans invoices pertaining to a group - """ - return ChargePlanInvoice.query.join(ChargeSubscription).join(Customer).join( - Company).filter(Company.id == self.group.id).all() - - -class PlanInvController(GroupController): - """ - Methods pertaining to a single plan invoice - """ - - def __init__(self): - super(PlanInvController, self).__init__() - plan_inv_id = request.view_args.values()[0] - self.invoice = ChargePlanInvoice.query.filter( - ChargePlanInvoice.id == plan_inv_id).first() - if not self.invoice: - raise BillyExc['404_PLAN_INV_NOT_FOUND'] - - @marshal_with(plan_inv_view) - def get(self, plan_inv_id): - """ - Retrieve a single invoice - """ - return self.invoice diff --git a/billy/api/resources/plan_invoice/view.py b/billy/api/resources/plan_invoice/view.py deleted file mode 100644 index 957c57e..0000000 --- a/billy/api/resources/plan_invoice/view.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -plan_inv_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'plan_id': fields.String(attribute='subscription.plan.your_id'), - 'customer_id': fields.String(attribute='subscription.customer.your_id'), - 'subscription_id': fields.String(attribute='subscription.id'), - 'relevant_coupon': fields.String(), - 'start_dt': fields.DateTime(), - 'end_dt': fields.DateTime(), - 'original_end_dt': fields.DateTime(), - 'prorated': fields.Boolean(), - 'charge_at_period_end': fields.Boolean(), - 'includes_trial': fields.Boolean(), - 'amount_base_cents': fields.Integer(), - 'amount_after_coupon_cents': fields.Integer(), - 'amount_paid_cents': fields.Integer(), - 'quantity': fields.Integer(), - 'remaining_balance_cents': fields.Integer(), - 'cleared_by_txn': fields.String(), -} diff --git a/billy/api/resources/plan_subscription/__init__.py b/billy/api/resources/plan_subscription/__init__.py deleted file mode 100644 index 069a89b..0000000 --- a/billy/api/resources/plan_subscription/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Customer, Company, ChargeSubscription -from .form import PlanSubCreateForm, PlanSubDeleteForm -from .view import plan_sub_view - - -class PlanSubIndexController(GroupController): - """ - Base ChargeSubscription resource used to create a plan subscription or - retrieve all your plan subscriptions - """ - - @marshal_with(plan_sub_view) - def get(self): - """ - Return a list of plans subscriptions pertaining to a group - """ - return ChargeSubscription.query.join(Customer).join(Company).filter( - Company.id == self.group.id).all() - - @marshal_with(plan_sub_view) - def post(self): - """ - Create or update a plan subscription - """ - sub_form = PlanSubCreateForm(request.form) - if sub_form.validate(): - return sub_form.save(self.group) - else: - self.form_error(sub_form.errors) - - @marshal_with(plan_sub_view) - def delete(self): - """ - Unsubscribe from the plan - """ - plan_form = PlanSubDeleteForm(request.form) - if plan_form.validate(): - return plan_form.save(self.group) - else: - self.form_error(plan_form.errors) - - -class PlanSubController(GroupController): - """ - Methods pertaining to a single plan subscription - """ - - def __init__(self): - super(PlanSubController, self).__init__() - plan_sub_id = request.view_args.values()[0] - self.subscription = ChargeSubscription.query.filter( - ChargeSubscription.id == plan_sub_id).first() - if not self.subscription: - raise BillyExc['404_PLAN_SUB_NOT_FOUND'] - - @marshal_with(plan_sub_view) - def get(self, plan_sub_id): - """ - Retrieve a single subscription - """ - return self.subscription diff --git a/billy/api/resources/plan_subscription/form.py b/billy/api/resources/plan_subscription/form.py deleted file mode 100644 index dbf78c1..0000000 --- a/billy/api/resources/plan_subscription/form.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy.orm.exc import * -from wtforms import ( - Form, TextField, IntegerField, validators, DateTimeField, BooleanField) - -from billy.api.errors import BillyExc -from billy.models import Customer, ChargePlan, ChargeSubscription - - -class PlanSubCreateForm(Form): - customer_id = TextField('Customer ID', [validators.Required(), - validators.Length(min=5, max=150)]) - plan_id = TextField('ChargePlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - - quantity = IntegerField('Quantity', default=1) - - charge_at_period_end = BooleanField('Charge at period end?', default=False) - - start_dt = DateTimeField('Start Datetime', default=None) - - def save(self, group_obj): - try: - customer = Customer.retrieve(self.customer_id.data, group_obj.id) - if not customer: - raise BillyExc['404_CUSTOMER_NOT_FOUND'] - plan = ChargePlan.retrieve(self.plan_id.data, group_obj.id) - if not plan: - raise BillyExc['404_PLAN_NOT_FOUND'] - return ChargeSubscription.subscribe(customer, plan, - quantity=self.quantity.data, - charge_at_period_end=self.charge_at_period_end.data, - start_dt=self.start_dt.data).subscription - except ValueError, e: - raise BillyExc[e.message] - - -class PlanSubDeleteForm(Form): - customer_id = TextField('Customer ID', [validators.Required(), - validators.Length(min=5, max=150)]) - plan_id = TextField('ChargePlan ID', [validators.Required(), - validators.Length(min=5, max=150)]) - - cancel_at_period_end = BooleanField('Cancel at period end?', default=False) - - def save(self, group_obj): - try: - customer = Customer.retrieve(self.customer_id.data, group_obj.id) - if not customer: - raise BillyExc['404_CUSTOMER_NOT_FOUND'] - plan = ChargePlan.retrieve(self.plan_id.data, group_obj.id) - if not plan: - raise BillyExc['404_PLAN_NOT_FOUND'] - return ChargeSubscription.unsubscribe(customer, plan, - cancel_at_period_end=self.cancel_at_period_end.data).subscription - except NoResultFound: - raise BillyExc['404_PLAN_SUB_NOT_FOUND'] - except ValueError, e: - raise BillyExc[e.message] diff --git a/billy/api/resources/plan_subscription/view.py b/billy/api/resources/plan_subscription/view.py deleted file mode 100644 index 34a6af2..0000000 --- a/billy/api/resources/plan_subscription/view.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -plan_sub_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'plan_id': fields.String(attribute='plan.your_id'), - 'customer_id': fields.String(attribute='customer.your_id'), - 'is_active': fields.Boolean(), - 'is_enrolled': fields.Boolean(), - # Todo add invoices field -} diff --git a/billy/api/resources/plan_transaction/__init__.py b/billy/api/resources/plan_transaction/__init__.py deleted file mode 100644 index 8bec4dd..0000000 --- a/billy/api/resources/plan_transaction/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import unicode_literals - -from flask import request -from flask.ext.restful import marshal_with - -from billy.api.errors import BillyExc -from billy.api.resources.group import GroupController -from billy.models import Company, Customer, ChargeTransaction -from .view import plan_trans_view - - -class PlanTransIndexController(GroupController): - """ - Base ChargePlan Transaction resource used to create a plan transaction or - retrieve all your plan transactions - """ - - @marshal_with(plan_trans_view) - def get(self): - """ - Return a list of plan transactions pertaining to a group - """ - return ChargeTransaction.query.join(Customer).join( - Company).filter(Company.id == self.group.id).all() - - -class PlanTransController(GroupController): - """ - Methods pertaining to a single plan transaction - """ - - def __init__(self): - super(PlanTransController, self).__init__() - plan_trans_id = request.view_args.values()[0] - self.trans = ChargeTransaction.query.filter( - ChargeTransaction.id == plan_trans_id).first() - if not self.trans: - raise BillyExc['404_PLAN_TRANS_NOT_FOUND'] - - @marshal_with(plan_trans_view) - def get(self, plan_trans_id): - """ - Retrieve a single transaction - """ - return self.trans diff --git a/billy/api/resources/plan_transaction/view.py b/billy/api/resources/plan_transaction/view.py deleted file mode 100644 index 5f133c9..0000000 --- a/billy/api/resources/plan_transaction/view.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils import fields - -plan_trans_view = { - # Todo: figure out why some arent showing... - 'id': fields.String(attribute='id'), - 'created_at': fields.DateTime(), - 'invoices': fields.String(attribute='payout_invoices'), - 'customer_id': fields.String(), - 'amount_cents': fields.Integer(), - 'status': fields.String(), - 'provider_txn_id': fields.String(), -} diff --git a/billy/api/spec.json b/billy/api/spec.json deleted file mode 100644 index 7801633..0000000 --- a/billy/api/spec.json +++ /dev/null @@ -1,1116 +0,0 @@ -{ - "errors": { - "400_PAYOUT_INTERVAL": { - "status": 400, - "error_message": "Invalid payout_interval. Please check.", - "error_code": "400_PAYOUT_INTERVAL" - }, - "400_QUANTITY": { - "status": 400, - "error_message": "Invalid quantity. Please check.", - "error_code": "400_QUANTITY" - }, - "409_PLAN_ALREADY_EXISTS": { - "status": 409, - "error_message": "Cannot perform POST on an existing plan. Use PUT instead.", - "error_code": "409_PLAN_ALREADY_EXISTS" - }, - "400_PERCENT_OFF_INT": { - "status": 400, - "error_message": "Invalid percent_off_int. Please check.", - "error_code": "400_PERCENT_OFF_INT" - }, - "400_EXPIRE_AT": { - "status": 400, - "error_message": "Invalid expire_at. Please check.", - "error_code": "400_EXPIRE_AT" - }, - "409_CUSTOMER_ALREADY_EXISTS": { - "status": 409, - "error_message": "Cannot perform POST on an existing customer. Use PUT instead.", - "error_code": "409_CUSTOMER_ALREADY_EXISTS" - }, - "400_PAYOUT_ID": { - "status": 400, - "error_message": "Invalid coupon_id. Please check.", - "error_code": "400_PAYOUT_ID" - }, - "400_CUSTOMER_ID": { - "status": 400, - "error_message": "Invalid customer_id. Please check.", - "error_code": "400_CUSTOMER_ID" - }, - "409_COUPON_ALREADY_EXISTS": { - "status": 409, - "error_message": "Cannot perform POST on an existing coupon. Use PUT instead.", - "error_code": "409_COUPON_ALREADY_EXISTS" - }, - "404_PAYOUT_SUB_NOT_FOUND": { - "status": 404, - "error_message": "The payout subscription you requested was not found.", - "error_code": "404_PAYOUT_SUB_NOT_FOUND" - }, - "400_COUPON_ID": { - "status": 400, - "error_message": "Invalid coupon_id. Please check.", - "error_code": "400_COUPON_ID" - }, - "404_PAYOUT_TRANS_NOT_FOUND": { - "status": 404, - "error_message": "The payout transaction you requested was not found.", - "error_code": "404_PAYOUT_TRANS_NOT_FOUND" - }, - "404_PLAN_TRANS_NOT_FOUND": { - "status": 404, - "error_message": "The plan transaction you requested was not found.", - "error_code": "404_PLAN_TRANS_NOT_FOUND" - }, - "409_PAYOUT_ALREADY_EXISTS": { - "status": 409, - "error_message": "Cannot perform POST on an existing payout. Use PUT instead.", - "error_code": "409_PAYOUT_ALREADY_EXISTS" - }, - "400_PRICE_CENTS": { - "status": 400, - "error_message": "Invalid price_cents. Please check.", - "error_code": "400_PRICE_CENTS" - }, - "404_PAYOUT_INV_NOT_FOUND": { - "status": 404, - "error_message": "The payout invoice you requested was not found.", - "error_code": "404_PAYOUT_INV_NOT_FOUND" - }, - "404_PLAN_NOT_FOUND": { - "status": 404, - "error_message": "The plan you requested was not found.", - "error_code": "404_PLAN_NOT_FOUND" - }, - "400_BALANCE_TO_KEEP_CENTS": { - "status": 400, - "error_message": "Invalid balance_to_keep_cents. Please check.", - "error_code": "400_BALANCE_TO_KEEP_CENTS" - }, - "400_NAME": { - "status": 400, - "error_message": "Invalid name. Please check.", - "error_code": "400_NAME" - }, - "404_PAYOUT_NOT_FOUND": { - "status": 404, - "error_message": "The payout you requested was not found.", - "error_code": "404_PAYOUT_NOT_FOUND" - }, - "400_PRICE_OFF_CENTS": { - "status": 400, - "error_message": "Invalid price_off_cents. Please check.", - "error_code": "400_PRICE_OFF_CENTS" - }, - "404_PLAN_SUB_NOT_FOUND": { - "status": 404, - "error_message": "The plan subscription you requested was not found.", - "error_code": "404_PLAN_SUB_NOT_FOUND" - }, - "404_PLAN_INV_NOT_FOUND": { - "status": 404, - "error_message": "The plan invoice you requested was not found.", - "error_code": "404_PLAN_INV_NOT_FOUND" - }, - "400_PLAN_INTERVAL": { - "status": 400, - "error_message": "Invalid plan_interval. Please check.", - "error_code": "400_PLAN_INTERVAL" - }, - "409_COUPON_MAX_REDEEM": { - "status": 409, - "error_message": "The coupon has already been redeemed maximum times", - "error_code": "409_COUPON_MAX_REDEEM" - }, - "404_CUSTOMER_NOT_FOUND": { - "status": 404, - "error_message": "The customer you requested was not found.", - "error_code": "404_CUSTOMER_NOT_FOUND" - }, - "400_REPEATING": { - "status": 400, - "error_message": "Invalid repeating. Please check.", - "error_code": "400_REPEATING" - }, - "400_MAX_REDEEM": { - "status": 400, - "error_message": "Invalid max_redeem. Please check.", - "error_code": "400_MAX_REDEEM" - }, - "401": { - "status": 401, - "error_message": "UnAuthorized: Invalid API Key", - "error_code": "401" - }, - "400": { - "status": 400, - "error_message": "Please check your request parameters.", - "error_code": "400" - }, - "400_PLAN_ID": { - "status": 400, - "error_message": "Invalid coupon_id. Please check.", - "error_code": "400_PLAN_ID" - }, - "404_COUPON_NOT_FOUND": { - "status": 404, - "error_message": "The coupon you requested was not found.", - "error_code": "404_COUPON_NOT_FOUND" - }, - "400_TRIAL_INTERVAL": { - "status": 400, - "error_message": "Invalid trial_interval. Please check.", - "error_code": "400_TRIAL_INTERVAL" - } - }, - "resources": { - "customer": { - "methods": { - "PUT": { - "description": "Update a customer, currently limited to updating their coupon.", - "form_fields": [ - { - "type": "STRING", - "name": "coupon_id" - } - ] - }, - "GET": { - "description": "Retrieve a single customer" - } - }, - "description": "Methods pertaining to a single customer", - "path": "/customer//", - "view": { - "balanced_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "charge_attempts": { - "type": "INTEGER" - }, - "id": { - "type": "STRING" - }, - "last_debt_clear": { - "type": "DATETIME" - }, - "current_coupon": { - "type": "STRING" - } - } - }, - "payout": { - "methods": { - "PUT": { - "description": "Update the name of a payout", - "form_fields": [ - { - "type": "STRING", - "name": "name" - } - ] - }, - "DELETE": { - "description": "Deletes a payout by marking it inactive. Does not effect users already\n on the payout." - }, - "GET": { - "description": "Retrieve a single payout" - } - }, - "description": "Methods pertaining to a single payout", - "path": "/payout//", - "view": { - "name": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "payout_interval": { - "type": "INTERVAL" - }, - "balance_to_keep_cents": { - "type": "INTEGER" - }, - "active": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - } - } - }, - "payout_index": { - "methods": { - "POST": { - "description": "Create a payout", - "form_fields": [ - { - "type": "INTEGER", - "name": "balance_to_keep_cents" - }, - { - "type": "STRING", - "name": "payout_interval" - }, - { - "type": "STRING", - "name": "name" - }, - { - "type": "STRING", - "name": "payout_id" - } - ] - }, - "GET": { - "description": "Return a list of payouts pertaining to a group" - } - }, - "description": "Base PayoutPlan resource used to create a payout or retrieve all your payouts", - "path": "/payout/", - "view": { - "name": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "payout_interval": { - "type": "INTERVAL" - }, - "balance_to_keep_cents": { - "type": "INTEGER" - }, - "active": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - } - } - }, - "group": { - "path": "/auth/", - "description": "Base authentication route that converts an API key to a group", - "methods": { - "GET": { - "description": "Used to test api_key and authentication" - } - }, - "view": null - }, - "customers_index": { - "methods": { - "POST": { - "description": "Create a customer", - "form_fields": [ - { - "type": "STRING", - "name": "balanced_id" - }, - { - "type": "STRING", - "name": "customer_id" - } - ] - }, - "GET": { - "description": "Return a list of customers pertaining to a group" - } - }, - "description": "Base customer resource used to create a customer or retrieve all your customers", - "path": "/customer/", - "view": { - "balanced_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "charge_attempts": { - "type": "INTEGER" - }, - "id": { - "type": "STRING" - }, - "last_debt_clear": { - "type": "DATETIME" - }, - "current_coupon": { - "type": "STRING" - } - } - }, - "coupon": { - "methods": { - "PUT": { - "description": "Update a customer, currently limited to updating their coupon.", - "form_fields": [ - { - "type": "INTEGER", - "name": "repeating" - }, - { - "type": "STRING", - "name": "name" - }, - { - "type": "DATETIME", - "name": "expire_at" - }, - { - "type": "INTEGER", - "name": "max_redeem" - } - ] - }, - "DELETE": { - "description": "Deletes a coupon by marking it inactive. Does not effect users already\n on the coupon." - }, - "GET": { - "description": "Retrieve a single coupon" - } - }, - "description": "Methods pertaining to a single coupon", - "path": "/coupon//", - "view": { - "name": { - "type": "STRING" - }, - "price_off_cents": { - "type": "INTEGER" - }, - "max_redeem": { - "type": "INTEGER" - }, - "created_at": { - "type": "DATETIME" - }, - "coupon_id": { - "type": "STRING" - }, - "active": { - "type": "BOOLEAN" - }, - "repeating": { - "type": "INTEGER" - }, - "percent_off_int": { - "type": "INTEGER" - }, - "expire_at": { - "type": "DATETIME" - } - } - }, - "plan_subscription_index": { - "methods": { - "POST": { - "description": "Create or update a plan subscription", - "form_fields": [ - { - "type": "DATETIME", - "name": "start_dt" - }, - { - "type": "BOOLEAN", - "name": "charge_at_period_end" - }, - { - "type": "STRING", - "name": "customer_id" - }, - { - "type": "STRING", - "name": "plan_id" - }, - { - "type": "INTEGER", - "name": "quantity" - } - ] - }, - "DELETE": { - "description": "Unsubscribe from the plan", - "form_fields": [ - { - "type": "BOOLEAN", - "name": "cancel_at_period_end" - }, - { - "type": "STRING", - "name": "customer_id" - }, - { - "type": "STRING", - "name": "plan_id" - } - ] - }, - "GET": { - "description": "Return a list of plans subscriptions pertaining to a group" - } - }, - "description": "Base ChargeSubscription resource used to create a plan subscription or retrieve all your plan subscriptions", - "path": "/plan_subscription/", - "view": { - "plan_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "is_active": { - "type": "BOOLEAN" - }, - "is_enrolled": { - "type": "BOOLEAN" - }, - "customer_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - } - }, - "payout_subscription": { - "path": "/payout_subscription//", - "view": { - "created_at": { - "type": "DATETIME" - }, - "customer_id": { - "type": "STRING" - }, - "is_active": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - }, - "payout_id": { - "type": "STRING" - } - }, - "description": "Methods pertaining to a single payout subscription", - "methods": { - "GET": { - "description": "Retrieve a single subscription" - } - } - }, - "plan_subscription": { - "path": "/plan_subscription//", - "view": { - "plan_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "is_active": { - "type": "BOOLEAN" - }, - "is_enrolled": { - "type": "BOOLEAN" - }, - "customer_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - }, - "description": "Methods pertaining to a single plan subscription", - "methods": { - "GET": { - "description": "Retrieve a single subscription" - } - } - }, - "payout_invoice_index": { - "path": "/payout_invoice/", - "view": { - "payout_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "amount_paid_out": { - "type": "INTEGER" - }, - "payout_dt": { - "type": "DATETIME" - }, - "attempts_made": { - "type": "INTEGER" - }, - "cleared_by_txn": { - "type": "STRING" - }, - "subscription_id": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - }, - "balance_at_exec": { - "type": "INTEGER" - } - }, - "description": "Base PayoutPlanInvoice resource used to create a payout invoice or retrieve all your payout invoices", - "methods": { - "GET": { - "description": "Return a list of payout invoices pertaining to a group" - } - } - }, - "payout_transaction": { - "path": "/payout_transaction//", - "view": { - "status": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "amount_cents": { - "type": "INTEGER" - }, - "invoices": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "provider_txn_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - }, - "description": "Methods pertaining to a single payout transaction", - "methods": { - "GET": { - "description": "Retrieve a single transaction" - } - } - }, - "plan_transaction_index": { - "path": "/plan_transaction/", - "view": { - "status": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "amount_cents": { - "type": "INTEGER" - }, - "invoices": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "provider_txn_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - }, - "description": "Base ChargePlan Transaction resource used to create a plan transaction or retrieve all your plan transactions", - "methods": { - "GET": { - "description": "Return a list of plan transactions pertaining to a group" - } - } - }, - "plan_transaction": { - "path": "/plan_transaction//", - "view": { - "status": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "amount_cents": { - "type": "INTEGER" - }, - "invoices": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "provider_txn_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - }, - "description": "Methods pertaining to a single plan transaction", - "methods": { - "GET": { - "description": "Retrieve a single transaction" - } - } - }, - "plan": { - "methods": { - "PUT": { - "description": "Update the name of a plan", - "form_fields": [ - { - "type": "STRING", - "name": "name" - } - ] - }, - "DELETE": { - "description": "Deletes a plan by marking it inactive. Does not effect users already\n on the plan." - }, - "GET": { - "description": "Retrieve a single plan" - } - }, - "description": "Methods pertaining to a single plan", - "path": "/plan//", - "view": { - "price_cents": { - "type": "INTEGER" - }, - "name": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "id": { - "type": "STRING" - }, - "trial_interval": { - "type": "INTERVAL" - }, - "active": { - "type": "BOOLEAN" - }, - "plan_interval": { - "type": "INTERVAL" - } - } - }, - "plan_invoice_index": { - "path": "/plan_invoice/", - "view": { - "amount_base_cents": { - "type": "INTEGER" - }, - "customer_id": { - "type": "STRING" - }, - "start_dt": { - "type": "DATETIME" - }, - "plan_id": { - "type": "STRING" - }, - "amount_after_coupon_cents": { - "type": "INTEGER" - }, - "created_at": { - "type": "DATETIME" - }, - "prorated": { - "type": "BOOLEAN" - }, - "subscription_id": { - "type": "STRING" - }, - "includes_trial": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - }, - "original_end_dt": { - "type": "DATETIME" - }, - "amount_paid_cents": { - "type": "INTEGER" - }, - "cleared_by_txn": { - "type": "STRING" - }, - "charge_at_period_end": { - "type": "BOOLEAN" - }, - "relevant_coupon": { - "type": "STRING" - }, - "end_dt": { - "type": "DATETIME" - }, - "remaining_balance_cents": { - "type": "INTEGER" - }, - "quantity": { - "type": "INTEGER" - } - }, - "description": "Base ChargePlanInvoice resource used to create a plan invoice or retrieve all your plan invoices", - "methods": { - "GET": { - "description": "Return a list of plans invoices pertaining to a group" - } - } - }, - "plan_invoice": { - "path": "/plan_invoice//", - "view": { - "amount_base_cents": { - "type": "INTEGER" - }, - "customer_id": { - "type": "STRING" - }, - "start_dt": { - "type": "DATETIME" - }, - "plan_id": { - "type": "STRING" - }, - "amount_after_coupon_cents": { - "type": "INTEGER" - }, - "created_at": { - "type": "DATETIME" - }, - "prorated": { - "type": "BOOLEAN" - }, - "subscription_id": { - "type": "STRING" - }, - "includes_trial": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - }, - "original_end_dt": { - "type": "DATETIME" - }, - "amount_paid_cents": { - "type": "INTEGER" - }, - "cleared_by_txn": { - "type": "STRING" - }, - "charge_at_period_end": { - "type": "BOOLEAN" - }, - "relevant_coupon": { - "type": "STRING" - }, - "end_dt": { - "type": "DATETIME" - }, - "remaining_balance_cents": { - "type": "INTEGER" - }, - "quantity": { - "type": "INTEGER" - } - }, - "description": "Methods pertaining to a single plan invoice", - "methods": { - "GET": { - "description": "Retrieve a single invoice" - } - } - }, - "plan_index": { - "methods": { - "POST": { - "description": "Create a plan", - "form_fields": [ - { - "type": "INTEGER", - "name": "price_cents" - }, - { - "type": "STRING", - "name": "plan_interval" - }, - { - "type": "STRING", - "name": "plan_id" - }, - { - "type": "STRING", - "name": "name" - }, - { - "type": "STRING", - "name": "trial_interval" - } - ] - }, - "GET": { - "description": "Return a list of plans pertaining to a group" - } - }, - "description": "Base ChargePlan resource used to create a plan or retrieve all your plans", - "path": "/plan/", - "view": { - "price_cents": { - "type": "INTEGER" - }, - "name": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "id": { - "type": "STRING" - }, - "trial_interval": { - "type": "INTERVAL" - }, - "active": { - "type": "BOOLEAN" - }, - "plan_interval": { - "type": "INTERVAL" - } - } - }, - "coupon_index": { - "methods": { - "POST": { - "description": "Create a coupon", - "form_fields": [ - { - "type": "STRING", - "name": "name" - }, - { - "type": "INTEGER", - "name": "price_off_cents" - }, - { - "type": "INTEGER", - "name": "max_redeem" - }, - { - "type": "STRING", - "name": "coupon_id" - }, - { - "type": "INTEGER", - "name": "repeating" - }, - { - "type": "INTEGER", - "name": "percent_off_int" - }, - { - "type": "DATETIME", - "name": "expire_at" - } - ] - }, - "GET": { - "description": "Return a list of coupon pertaining to a group" - } - }, - "description": "Base coupon resource used to create a coupon or retrieve all your coupons", - "path": "/coupon/", - "view": { - "name": { - "type": "STRING" - }, - "price_off_cents": { - "type": "INTEGER" - }, - "max_redeem": { - "type": "INTEGER" - }, - "created_at": { - "type": "DATETIME" - }, - "coupon_id": { - "type": "STRING" - }, - "active": { - "type": "BOOLEAN" - }, - "repeating": { - "type": "INTEGER" - }, - "percent_off_int": { - "type": "INTEGER" - }, - "expire_at": { - "type": "DATETIME" - } - } - }, - "payout_invoice": { - "path": "/payout_invoice//", - "view": { - "payout_id": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "amount_paid_out": { - "type": "INTEGER" - }, - "payout_dt": { - "type": "DATETIME" - }, - "attempts_made": { - "type": "INTEGER" - }, - "cleared_by_txn": { - "type": "STRING" - }, - "subscription_id": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - }, - "balance_at_exec": { - "type": "INTEGER" - } - }, - "description": "Methods pertaining to a single payout invoice", - "methods": { - "GET": { - "description": "Retrieve a single invoice" - } - } - }, - "payout_transaction_index": { - "path": "/payout_transaction/", - "view": { - "status": { - "type": "STRING" - }, - "customer_id": { - "type": "STRING" - }, - "amount_cents": { - "type": "INTEGER" - }, - "invoices": { - "type": "STRING" - }, - "created_at": { - "type": "DATETIME" - }, - "provider_txn_id": { - "type": "STRING" - }, - "id": { - "type": "STRING" - } - }, - "description": "Base PayoutPlan Transaction resource used to create a payout transaction or retrieve all your payout transactions", - "methods": { - "GET": { - "description": "Return a list of payout transactions pertaining to a group" - } - } - }, - "payout_subscription_index": { - "methods": { - "POST": { - "description": "Create or update a payout subscription", - "form_fields": [ - { - "type": "DATETIME", - "name": "start_dt" - }, - { - "type": "STRING", - "name": "customer_id" - }, - { - "type": "BOOLEAN", - "name": "first_now" - }, - { - "type": "STRING", - "name": "payout_id" - } - ] - }, - "DELETE": { - "description": "Unsubscribe from the payout", - "form_fields": [ - { - "type": "BOOLEAN", - "name": "cancel_at_period_end" - }, - { - "type": "STRING", - "name": "customer_id" - }, - { - "type": "STRING", - "name": "plan_id" - } - ] - }, - "GET": { - "description": "Return a list of payout subscriptions pertaining to a group" - } - }, - "description": "Base PayoutSubscription resource used to create a payout subscription or retrieve all your payout subscriptions", - "path": "/payout_subscription/", - "view": { - "created_at": { - "type": "DATETIME" - }, - "customer_id": { - "type": "STRING" - }, - "is_active": { - "type": "BOOLEAN" - }, - "id": { - "type": "STRING" - }, - "payout_id": { - "type": "STRING" - } - } - } - } -} \ No newline at end of file diff --git a/billy/api/spec.py b/billy/api/spec.py deleted file mode 100644 index 465673f..0000000 --- a/billy/api/spec.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import unicode_literals - -from wtforms import fields as wtfields - -from billy.utils import fields -from billy.api.errors.definitions import error_definitions -from billy.utils.intervals import IntervalViewField -from .resources import * - - -def get_methods(controller): - methods = ['GET', 'POST', 'PUT', 'DELETE'] - method_list = {} - for method in methods: - if hasattr(controller, method.lower()): - try: - doc = getattr(controller, method.lower()).__doc__.strip() - method_list[method] = { - 'description': doc, - } - except AttributeError, e: - print "ERROR {} has no doc.".format(getattr(controller, - method.lower())) - raise e - - return method_list - - -def get_view(view): - field_map = { - fields.String: 'STRING', - fields.DateTime: 'DATETIME', - fields.Integer: 'INTEGER', - fields.Boolean: "BOOLEAN", - IntervalViewField: 'INTERVAL', - } - if not view: - return view - data = {key: {'type': field_map[type(value)]} for key, value in - view.iteritems()} - return data - - -def get_doc(obj): - return None if not obj.__doc__ else ' '.join(obj.__doc__.split()).strip() - - -def process_forms(spec_item): - """ - Processes the forms in the spec items. - """ - - def process_form_class(form_class): - field_map = { - wtfields.TextField: 'STRING', - wtfields.IntegerField: "INTEGER", - wtfields.DateTimeField: "DATETIME", - wtfields.BooleanField: "BOOLEAN" - } - return [{'name':name, 'type': field_map[type(field_class)]} for - name, field_class in form_class()._fields.iteritems()] - - form = spec.get('form', {}) - for method, form_class in form.iteritems(): - method = method.upper() - assert method in spec['methods'], "Method not in methods!" - spec['methods'][method]['form_fields'] = process_form_class(form_class) - return spec_item - - -billy_spec = { - 'group': { - 'path': '/auth/', - 'controller': GroupController - }, - 'customers_index': { - 'path': '/customer/', - 'controller': CustomerIndexController, - 'view': customer_view, - 'form': { - 'post': CustomerCreateForm - } - }, - 'customer': { - 'path': '/customer//', - 'controller': CustomerController, - 'view': customer_view, - 'form': { - 'put': CustomerUpdateForm - } - }, - 'coupon_index': { - 'path': '/coupon/', - 'controller': CouponIndexController, - 'view': coupon_view, - 'form': { - 'post': CouponCreateForm - } - - }, - 'coupon': { - 'path': '/coupon//', - 'controller': CouponController, - 'view': coupon_view, - 'form': { - 'put': CouponUpdateForm - } - - }, - 'plan_index': { - 'path': '/plan/', - 'controller': PlanIndexController, - 'view': plan_view, - 'form': { - 'post': PlanCreateForm - } - - }, - 'plan': { - 'path': '/plan//', - 'controller': PlanController, - 'view': plan_view, - 'form': { - 'put': PlanUpdateForm - } - - }, - 'payout_index': { - 'path': '/payout/', - 'controller': PayoutIndexController, - 'view': payout_view, - 'form': { - 'post': PayoutCreateForm - } - - }, - 'payout': { - 'path': '/payout//', - 'controller': PayoutController, - 'view': payout_view, - 'form': { - 'put': PayoutUpdateForm - } - - }, - 'plan_subscription_index': { - 'path': '/plan_subscription/', - 'controller': PlanSubIndexController, - 'view': plan_sub_view, - 'form': { - 'post': PlanSubCreateForm, - 'delete': PlanSubDeleteForm - } - - }, - 'plan_subscription': { - 'path': '/plan_subscription//', - 'controller': PlanSubController, - 'view': plan_sub_view - - }, - 'payout_subscription_index': { - 'path': '/payout_subscription/', - 'controller': PayoutSubIndexController, - 'view': payout_sub_view, - 'form': { - 'post': PayoutSubCreateForm, - 'delete': PlanSubDeleteForm - } - - }, - 'payout_subscription': { - 'path': '/payout_subscription//', - 'controller': PayoutSubController, - 'view': payout_sub_view - - }, - 'plan_invoice_index': { - 'path': '/plan_invoice/', - 'controller': PlanInvIndexController, - 'view': plan_inv_view, - - }, - 'plan_invoice': { - 'path': '/plan_invoice//', - 'controller': PlanInvController, - 'view': plan_inv_view - - }, - 'payout_invoice_index': { - 'path': '/payout_invoice/', - 'controller': PayoutInvIndexController, - 'view': payout_inv_view - - }, - 'payout_invoice': { - 'path': '/payout_invoice//', - 'controller': PayoutInvController, - 'view': payout_inv_view - - }, - 'plan_transaction_index': { - 'path': '/plan_transaction/', - 'controller': PlanTransIndexController, - 'view': plan_trans_view - - }, - 'plan_transaction': { - 'path': '/plan_transaction//', - 'controller': PlanTransController, - 'view': plan_trans_view - - }, - 'payout_transaction_index': { - 'path': '/payout_transaction/', - 'controller': PayoutTransIndexController, - 'view': payout_trans_view - - }, - 'payout_transaction': { - 'path': '/payout_transaction//', - 'controller': PayoutTransController, - 'view': payout_trans_view - - }, - -} - -billy_spec_processed = {'resources': {}, 'errors': {}} -for resource, spec in billy_spec.iteritems(): - spec['methods'] = get_methods(spec['controller']) - spec['description'] = get_doc(spec['controller']) - spec['view'] = get_view(spec.get('view')) - spec_new = process_forms(spec.copy()) - del spec_new['controller'] - if 'form' in spec_new: - del spec_new['form'] - billy_spec_processed['resources'][resource] = spec_new - billy_spec_processed['errors'] = error_definitions - -if __name__ == '__main__': - import json - - with open('spec.json', 'w+') as spec_file: - json.dump(billy_spec_processed, spec_file, indent=4) - print('Spec written successfully.') diff --git a/billy/api/subscription/__init__.py b/billy/api/subscription/__init__.py new file mode 100644 index 0000000..9f92738 --- /dev/null +++ b/billy/api/subscription/__init__.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('subscription', '/subscriptions/{subscription_guid}') + config.add_route('subscription_cancel', + '/subscriptions/{subscription_guid}/cancel') + config.add_route('subscription_list', '/subscriptions/') diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py new file mode 100644 index 0000000..b566781 --- /dev/null +++ b/billy/api/subscription/forms.py @@ -0,0 +1,99 @@ +from __future__ import unicode_literals + +import pytz +import iso8601 +from wtforms import Form +from wtforms import TextField +from wtforms import DecimalField +from wtforms import BooleanField +from wtforms import Field +from wtforms import validators + +from billy.models import tables +from billy.models.customer import CustomerModel +from billy.models.plan import PlanModel +from billy.api.utils import RecordExistValidator + + +class ISO8601Field(Field): + """This filed validates and converts input ISO8601 into UTC naive + datetime + + """ + + def process_formdata(self, valuelist): + if not valuelist: + return + try: + self.data = iso8601.parse_date(valuelist[0]) + except iso8601.ParseError: + raise ValueError(self.gettext('Invalid ISO8601 datetime {}') + .format(valuelist[0])) + self.data = self.data.astimezone(pytz.utc) + self.data = self.data.replace(tzinfo=None) + + +class NoPastValidator(object): + """Make sure a datetime is not in past + + """ + + def __init__(self, now_func=tables.now_func): + self.now_func = now_func + + def __call__(self, form, field): + if not field.data: + return + now = self.now_func() + if field.data < now: + msg = field.gettext('Datetime {} in the past is not allowed' + .format(field.data)) + raise ValueError(msg) + + +class RefundAmountConflict(object): + """Make sure prorated_refund=True with refund_amount is not allowed + + """ + def __call__(self, form, field): + prorated_refund = form['prorated_refund'].data + if prorated_refund and field.data is not None: + raise ValueError( + field.gettext('You cannot set refund_amount with ' + 'prorated_refund=True') + ) + + +class SubscriptionCreateForm(Form): + customer_guid = TextField('Customer GUID', [ + validators.Required(), + RecordExistValidator(CustomerModel), + ]) + plan_guid = TextField('Plan GUID', [ + validators.Required(), + RecordExistValidator(PlanModel), + ]) + payment_uri = TextField('Payment URI', [ + validators.Optional(), + ]) + amount = DecimalField('Amount', [ + validators.Optional(), + # TODO: what is the minimum amount limitation we have? + validators.NumberRange(min=0.01) + ]) + started_at = ISO8601Field('Started at datetime', [ + validators.Optional(), + NoPastValidator(), + ]) + + +class SubscriptionCancelForm(Form): + prorated_refund = BooleanField('Prorated refund', [ + validators.Optional(), + ], default=False) + refund_amount = DecimalField('Refund amount', [ + validators.Optional(), + RefundAmountConflict(), + # TODO: what is the minimum amount limitation we have? + validators.NumberRange(min=0.01), + ]) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py new file mode 100644 index 0000000..d550289 --- /dev/null +++ b/billy/api/subscription/views.py @@ -0,0 +1,143 @@ +from __future__ import unicode_literals + +import transaction as db_transaction +from pyramid.view import view_config +from pyramid.settings import asbool +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.customer import CustomerModel +from billy.models.plan import PlanModel +from billy.models.subscription import SubscriptionModel +from billy.models.transaction import TransactionModel +from billy.api.auth import auth_api_key +from billy.api.utils import validate_form +from billy.api.utils import form_errors_to_bad_request +from .forms import SubscriptionCreateForm +from .forms import SubscriptionCancelForm + + +def get_and_check_subscription(request, company, guid): + """Get and check permission to access a subscription + + """ + model = SubscriptionModel(request.session) + subscription = model.get(guid) + if subscription is None: + raise HTTPNotFound('No such subscription {}'.format(guid)) + if subscription.customer.company_guid != company.guid: + raise HTTPForbidden('You have no permission to access subscription {}' + .format(guid)) + return subscription + + +@view_config(route_name='subscription_list', + request_method='POST', + renderer='json') +def subscription_list_post(request): + """Create a new subscription + + """ + company = auth_api_key(request) + form = validate_form(SubscriptionCreateForm, request) + + customer_guid = form.data['customer_guid'] + plan_guid = form.data['plan_guid'] + amount = form.data.get('amount') + payment_uri = form.data.get('payment_uri') + started_at = form.data.get('started_at') + + model = SubscriptionModel(request.session) + plan_model = PlanModel(request.session) + customer_model = CustomerModel(request.session) + tx_model = TransactionModel(request.session) + + customer = customer_model.get(customer_guid) + if customer.company_guid != company.guid: + return HTTPForbidden('Can only subscribe to your own customer') + plan = plan_model.get(plan_guid) + if plan.company_guid != company.guid: + return HTTPForbidden('Can only subscribe to your own plan') + + # create subscription and yield transactions + with db_transaction.manager: + guid = model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + payment_uri=payment_uri, + started_at=started_at, + ) + tx_guids = model.yield_transactions([guid]) + # this is not a deferred subscription, just process transactions right away + if started_at is None: + with db_transaction.manager: + tx_model.process_transactions(request.processor, tx_guids) + + subscription = model.get(guid) + return subscription + + +@view_config(route_name='subscription', + request_method='GET', + renderer='json') +def subscription_get(request): + """Get and return a subscription + + """ + company = auth_api_key(request) + guid = request.matchdict['subscription_guid'] + subscription = get_and_check_subscription(request, company, guid) + return subscription + + +@view_config(route_name='subscription_cancel', + request_method='POST', + renderer='json') +def subscription_cancel(request): + """Cancel a subscription + + """ + # TODO: it appears a DELETE request with body is not a good idea + # for HTTP protocol as many server doesn't support this, this is why + # we use another view with post method, maybe we should use a better + # approach later + company = auth_api_key(request) + form = validate_form(SubscriptionCancelForm, request) + + guid = request.matchdict['subscription_guid'] + prorated_refund = asbool(form.data.get('prorated_refund', False)) + refund_amount = form.data.get('refund_amount') + + model = SubscriptionModel(request.session) + tx_model = TransactionModel(request.session) + get_and_check_subscription(request, company, guid) + + # TODO: maybe we can find a better way to integrate this with the + # form validation? + if refund_amount is not None: + subscription = model.get(guid) + if subscription.amount is not None: + amount = subscription.amount + else: + amount = subscription.plan.amount + if refund_amount > amount: + return form_errors_to_bad_request(dict( + refund_amount=['refund_amount cannot be greater than ' + 'subscription amount {}'.format(amount)] + )) + + # TODO: make sure the subscription is not already canceled + + with db_transaction.manager: + tx_guid = model.cancel( + guid, + prorated_refund=prorated_refund, + refund_amount=refund_amount, + ) + if tx_guid is not None: + with db_transaction.manager: + tx_model.process_transactions(request.processor, [tx_guid]) + + subscription = model.get(guid) + return subscription diff --git a/billy/api/transaction/__init__.py b/billy/api/transaction/__init__.py new file mode 100644 index 0000000..5b40636 --- /dev/null +++ b/billy/api/transaction/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('transaction_list', '/transactions/') + config.add_route('transaction', '/transactions/{transaction_guid}') diff --git a/billy/api/transaction/views.py b/billy/api/transaction/views.py new file mode 100644 index 0000000..7eb55b6 --- /dev/null +++ b/billy/api/transaction/views.py @@ -0,0 +1,51 @@ +from __future__ import unicode_literals + +from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden + +from billy.models.transaction import TransactionModel +from billy.api.auth import auth_api_key + + +@view_config(route_name='transaction_list', + request_method='GET', + renderer='json') +def transaction_list_get(request): + """Get and return transactions + + """ + company = auth_api_key(request) + model = TransactionModel(request.session) + offset = int(request.params.get('offset', 0)) + limit = int(request.params.get('limit', 20)) + transactions = model.list_by_company_guid( + company_guid=company.guid, + offset=offset, + limit=limit, + ) + result = dict( + items=list(transactions), + offset=offset, + limit=limit, + ) + return result + + +@view_config(route_name='transaction', + request_method='GET', + renderer='json') +def transaction_get(request): + """Get and return a transaction + + """ + company = auth_api_key(request) + model = TransactionModel(request.session) + guid = request.matchdict['transaction_guid'] + transaction = model.get(guid) + if transaction is None: + return HTTPNotFound('No such transaction {}'.format(guid)) + if transaction.subscription.customer.company_guid != company.guid: + return HTTPForbidden('You have no permission to access transaction {}' + .format(guid)) + return transaction diff --git a/billy/api/utils.py b/billy/api/utils.py new file mode 100644 index 0000000..f969d56 --- /dev/null +++ b/billy/api/utils.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +from pyramid.httpexceptions import HTTPBadRequest + + +def form_errors_to_bad_request(errors): + """Convert WTForm errors into readable bad request + + """ + error_params = [] + error_params.append('
    ') + for param_key, param_errors in errors.iteritems(): + indent = ' ' * 4 + error_params.append(indent + '
  • ') + indent = ' ' * 8 + error_params.append(indent + '{}:
      '.format(param_key)) + for param_error in param_errors: + indent = ' ' * 12 + error_params.append(indent + '
    • {}
    • '.format(param_error)) + indent = ' ' * 8 + error_params.append(indent + '
    ') + indent = ' ' * 4 + error_params.append(indent + '
  • ') + error_params.append('
') + error_params = '\n'.join(error_params) + message = "There are errors in following parameters: {}".format(error_params) + return HTTPBadRequest(message) + + +def validate_form(form_cls, request): + """Validate form and raise exception if necessary + + """ + form = form_cls(request.params) + # Notice: this make validators can query to database + form.session = request.session + validation_result = form.validate() + if not validation_result: + raise form_errors_to_bad_request(form.errors) + return form + + +class RecordExistValidator(object): + """This validator make sure there is a record exists for a given GUID + + """ + + def __init__(self, model_cls): + self.model_cls = model_cls + + def __call__(self, form, field): + # Notice: we should set form.session before we call validate + model = self.model_cls(form.session) + if model.get(field.data) is None: + msg = field.gettext('No such {} record {}' + .format(self.model_cls.__name__, field.data)) + raise ValueError(msg) diff --git a/billy/manage.py b/billy/manage.py deleted file mode 100644 index 970c0f3..0000000 --- a/billy/manage.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import unicode_literals - -from flask.ext.script import Manager - -from billy.models import * -from billy.api.app import app -from billy.settings import DB_ENGINE, DEBUG - -manager = Manager(app) - - -@manager.command -def create_tables(): - """ - Creates the tables if they dont exists - """ - Base.metadata.create_all(DB_ENGINE) - print "Create tables.... DONE" - - -@manager.command -def delete_and_replace_tables(): - """ - Deletes and replaces the tables. - Warning very destructive on production data. - """ - assert DEBUG - for table in Base.metadata.sorted_tables: - table.delete() - print "Delete tables.... DONE" - create_tables() - - -@manager.command -def billy_tasks(): - """ - The main billy task that does EVERYTHING cron related. - """ - Coupon.expire_coupons() - Customer.settle_all_charge_plan_debt() - ChargePlanInvoice.reinvoice_all() - PayoutPlanInvoice.make_all_payouts() - PayoutPlanInvoice.reinvoice_all() - print "Billy task.... DONE" - - -@manager.command -def run_api(): - app.debug = DEBUG - app.run() - - -if __name__ == "__main__": - manager.run() diff --git a/billy/models/#billy.uml b/billy/models/#billy.uml deleted file mode 100644 index 1a502dd..0000000 --- a/billy/models/#billy.uml +++ /dev/null @@ -1,24 +0,0 @@ - - - AlchemyModelDependency - #billy - - models.transactions.PlanTransaction - models.payouts.Payout - models.coupons.Coupon - models.transactions.PayoutTransaction - models.plan_invoice.PlanInvoice - models.groups.Group - models.payout_invoice.PayoutInvoice - models.plans.Plan - models.customers.Customer - - - - - - - Fields - - - diff --git a/billy/models/__init__.py b/billy/models/__init__.py index d8fe2c5..4b1d879 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -1,17 +1,28 @@ from __future__ import unicode_literals +import datetime +from sqlalchemy import engine_from_config +from sqlalchemy.orm import scoped_session +from sqlalchemy.orm import sessionmaker +from zope.sqlalchemy import ZopeTransactionExtension + +from . import tables -from .processor import ProcessorType -from .base import Base -from .charge.subscription import ChargeSubscription -from .payout.subscription import PayoutSubscription -from .charge.invoice import ChargePlanInvoice -from .payout.invoice import PayoutPlanInvoice -from .charge.transaction import ChargeTransaction, ChargeTransactionStatus -from .payout.transaction import PayoutTransaction, PayoutTransactionStatus -from .payout.plan import PayoutPlan -from .charge.plan import ChargePlan -from .customers import Customer -from .coupons import Coupon -from .company import Company +def setup_database(global_config, **settings): + """Setup database + + """ + if 'engine' not in settings: + settings['engine'] = ( + engine_from_config(settings, 'sqlalchemy.') + ) + + if 'session' not in settings: + settings['session'] = scoped_session(sessionmaker( + extension=ZopeTransactionExtension(), + bind=settings['engine'] + )) + + tables.set_now_func(datetime.datetime.utcnow) + return settings diff --git a/billy/models/base.py b/billy/models/base.py deleted file mode 100644 index 1e390e1..0000000 --- a/billy/models/base.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import unicode_literals -import json -from datetime import datetime - -from dateutil.relativedelta import relativedelta -from sqlalchemy import Column, DateTime, event -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.types import TypeDecorator, VARCHAR - -from billy.settings import Session - - -class Base(object): - - query = Session.query_property() - session = Session - - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, - onupdate=datetime.utcnow, nullable=False) - - def __repr__(self): - cols = sorted(self.__mapper__.c.keys()) - class_name = self.__class__.__name__ - items = ', '.join(['\n%s=%s' % (col, repr(getattr(self, col))) for col - in cols]) - return '%s(%s)\n\n' % (class_name, items) - -Base = declarative_base(cls=Base) - - -class RelativeDelta(TypeDecorator): - - """ - A python dictionary to json type - """ - impl = VARCHAR - - def from_relativedelta(self, inter): - return { - 'years': inter.years, - 'months': inter.months, - 'days': inter.days, - 'hours': inter.hours, - } - - def to_relativedelta(self, param): - return relativedelta(years=param['years'], months=param['months'], - days=param['days'], hours=param['hours']) - - def process_bind_param(self, value, dialect): - if value and not isinstance(value, relativedelta): - raise ValueError("Accepts only relativedelta types") - if value: - data_json = self.from_relativedelta(value) - value = json.dumps(data_json) - return value - - def process_result_value(self, value, dialect): - if value is not None: - data = json.loads(value) - value = self.to_relativedelta(data) - return value diff --git a/billy/models/charge/invoice.py b/billy/models/charge/invoice.py deleted file mode 100644 index efb1ef8..0000000 --- a/billy/models/charge/invoice.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime -from decimal import Decimal - -from sqlalchemy import (Column, Unicode, ForeignKey, DateTime, Boolean, - Integer, CheckConstraint) -from sqlalchemy.orm import relationship, backref - -from billy.models import Base, ChargeSubscription -from billy import settings -from billy.utils.models import uuid_factory - - -class ChargePlanInvoice(Base): - __tablename__ = 'charge_plan_invoices' - - id = Column(Unicode, primary_key=True, default=uuid_factory('CPI')) - subscription_id = Column(Unicode, ForeignKey('charge_subscription.id', - ondelete='cascade'), - nullable=False) - coupon_id = Column(Unicode, ForeignKey('coupons.id', ondelete='cascade')) - start_dt = Column(DateTime, nullable=False) - end_dt = Column(DateTime, nullable=False) - original_end_dt = Column(DateTime) - due_dt = Column(DateTime, nullable=False) - includes_trial = Column(Boolean) - amount_base_cents = Column(Integer, nullable=False) - amount_after_coupon_cents = Column(Integer, nullable=False) - amount_paid_cents = Column(Integer, nullable=False) - remaining_balance_cents = Column(Integer, nullable=False) - quantity = Column( - Integer, CheckConstraint('quantity >= 0'), nullable=False) - prorated = Column(Boolean) - charge_at_period_end = Column(Boolean) - charge_attempts = Column(Integer, default=0) - - transaction = relationship('ChargeTransaction', backref='invoice', - cascade='delete', uselist=False) - - subscription = relationship('ChargeSubscription', - backref=backref('invoices', - cascade='delete,delete-orphan', - lazy='dynamic')) - - @classmethod - def create(cls, subscription, coupon, start_dt, end_dt, due_dt, - amount_base_cents, amount_after_coupon_cents, amount_paid_cents, - remaining_balance_cents, quantity, charge_at_period_end, - includes_trial=False): - invoice = cls( - subscription=subscription, - coupon=coupon, - start_dt=start_dt, - end_dt=end_dt, - due_dt=due_dt, - original_end_dt=end_dt, - amount_base_cents=amount_base_cents, - amount_after_coupon_cents=amount_after_coupon_cents, - amount_paid_cents=amount_paid_cents, - remaining_balance_cents=remaining_balance_cents, - quantity=quantity, - charge_at_period_end=charge_at_period_end, - includes_trial=includes_trial, - ) - cls.session.add(invoice) - return invoice - - @classmethod - def prorate_last(cls, customer, plan): - """ - Prorates the last invoice to now - """ - subscription = ChargeSubscription.query.filter( - ChargeSubscription.customer == customer, - ChargeSubscription.plan == plan, - ChargeSubscription.should_renew == True).first() - current_invoice = subscription and subscription.current_invoice - if current_invoice: - now = datetime.utcnow() - true_start = current_invoice.start_dt - if current_invoice.includes_trial and plan.trial_interval: - true_start = true_start + plan.trial_interval - if current_invoice: - time_total = Decimal( - (current_invoice.end_dt - true_start).total_seconds()) - time_used = Decimal( - (now - true_start).total_seconds()) - percent_used = time_used / time_total - new_base_amount = current_invoice.amount_base_cents * \ - percent_used - new_after_coupon_amount = \ - current_invoice.amount_after_coupon_cents * percent_used - new_balance = \ - new_after_coupon_amount - current_invoice.amount_paid_cents - current_invoice.amount_base_cents = new_base_amount - current_invoice.amount_after_coupon_cents = new_after_coupon_amount - current_invoice.remaining_balance_cents = new_balance - current_invoice.end_dt = now - current_invoice.prorated = True - return current_invoice - - @classmethod - def all_due(cls, customer): - """ - Returns a list of invoices that are due for a customers - """ - now = datetime.utcnow() - results = ChargePlanInvoice.query.filter( - ChargeSubscription.customer == customer, - ChargePlanInvoice.remaining_balance_cents != 0, - ChargePlanInvoice.due_dt <= now, - ).all() - return results - - @classmethod - def settle_all(cls): - """ - Main task to settle charge_plans. - """ - now = datetime.utcnow() - needs_settling = cls.query.filter( - cls.due_dt <= now, - cls.remaining_balance_cents > 0).all() - for invoice in needs_settling: - if len(settings.RETRY_DELAY_PLAN) < invoice.charge_attempts: - invoice.subscription.is_active = False - invoice.subscription.is_enrolled = False - retry_delay = sum( - settings.RETRY_DELAY_PLAN[:invoice.charge_attempts]) - when_to_charge = invoice.due_dt + retry_delay if retry_delay \ - else invoice.due_dt - if when_to_charge <= now: - invoice.settle() - return len(needs_settling) - - - def settle(self): - """ - Clears the charge debt of the customer. - """ - from models import ChargeTransaction - transaction = ChargeTransaction.create(self.subscription.customer, - self.remaining_balance_cents) - transaction.invoice_id = self.id - try: - - self.remaining_balance_cents = 0 - self.amount_paid_cents = transaction.amount_cents - except Exception, e: - self.charge_attempts += 1 - self.session.commit() - raise e - return self diff --git a/billy/models/charge/plan.py b/billy/models/charge/plan.py deleted file mode 100644 index 0aa9819..0000000 --- a/billy/models/charge/plan.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime -from decimal import Decimal - -from sqlalchemy import (Column, Unicode, Integer, Boolean, DateTime, - ForeignKey, UniqueConstraint, CheckConstraint) -from sqlalchemy.orm import relationship - -from billy.models import Base, ChargeSubscription, ChargePlanInvoice -from billy.models.base import RelativeDelta -from billy.utils.models import uuid_factory - - -class ChargePlan(Base): - __tablename__ = 'charge_plans' - - id = Column(Unicode, primary_key=True, default=uuid_factory('CP')) - your_id = Column(Unicode, nullable=False) - company_id = Column(Unicode, ForeignKey('companies.id', ondelete='cascade'), - nullable=False) - name = Column(Unicode, nullable=False) - price_cents = Column(Integer, CheckConstraint('price_cents >= 0'), - nullable=False) - active = Column(Boolean, default=True) - disabled_at = Column(DateTime) - trial_interval = Column(RelativeDelta) - plan_interval = Column(RelativeDelta) - - subscriptions = relationship('ChargeSubscription', backref='plan', - cascade='delete, delete-orphan') - - __table_args__ = (UniqueConstraint(your_id, company_id, - name='plan_id_company_unique'), - ) - - def subscribe(self, customer, quantity=1, - charge_at_period_end=False, start_dt=None, coupon=None): - """ - Subscribe a customer to a plan - """ - can_trial = self.can_customer_trial(customer) - subscription = ChargeSubscription.create(customer, self, coupon=coupon) - coupon = subscription.coupon - start_date = start_dt or datetime.utcnow() - due_on = start_date - end_date = start_date + self.plan_interval - if can_trial and self.trial_interval: - end_date += self.trial_interval - due_on += self.trial_interval - if charge_at_period_end: - due_on = end_date - amount_base = self.price_cents * Decimal(quantity) - amount_after_coupon = amount_base - - if subscription.coupon and coupon.can_use(customer): - dollars_off = coupon.price_off_cents - percent_off = coupon.percent_off_int - amount_after_coupon -= dollars_off # BOTH CENTS, safe - amount_after_coupon -= int( - amount_after_coupon * Decimal(percent_off) / Decimal(100)) - balance = amount_after_coupon - ChargePlanInvoice.prorate_last(customer, self) - ChargePlanInvoice.create( - subscription=subscription, - coupon=subscription.coupon, - start_dt=start_date, - end_dt=end_date, - due_dt=due_on, - amount_base_cents=amount_base, - amount_after_coupon_cents=amount_after_coupon, - amount_paid_cents=0, - remaining_balance_cents=balance, - quantity=quantity, - charge_at_period_end=charge_at_period_end, - includes_trial=can_trial - ) - return subscription - - def disable(self): - """ - Disables a charge plan. Does not effect current subscribers. - """ - self.active = False - self.disabled_at = datetime.utcnow() - return self - - def can_customer_trial(self, customer): - """ - Whether a customer can trial a charge plan - """ - return not ChargeSubscription.query.filter( - ChargeSubscription.customer == customer, - ChargeSubscription.plan == self - ).first() diff --git a/billy/models/charge/subscription.py b/billy/models/charge/subscription.py deleted file mode 100644 index ba33347..0000000 --- a/billy/models/charge/subscription.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from sqlalchemy import Column, Unicode, ForeignKey, Boolean, Index -from sqlalchemy.orm import relationship - -from billy.models import Base -from billy.utils.models import uuid_factory - - -class ChargeSubscription(Base): - __tablename__ = 'charge_subscription' - - id = Column(Unicode, primary_key=True, default=uuid_factory('CS')) - customer_id = Column(Unicode, - ForeignKey('customers.id', ondelete='cascade'), - nullable=False) - coupon_id = Column(Unicode, ForeignKey('coupons.id', ondelete='cascade')) - plan_id = Column(Unicode, ForeignKey('charge_plans.id', ondelete='cascade'), - nullable=False) - # is_enrolled and should_renew have paired states such as: - # 1) is_enrolled = True and should_renew = False when the subscription will - # end at the end of the current period - # 2) is_enrolled = True nd should_renew = True when the customer is on the - # plan and will continue to be on it - # 3) is_enrolled = False and should_renew = False when the customer is - # neither enrolled or will it renew - is_enrolled = Column(Boolean, default=True) - should_renew = Column(Boolean, default=True) - - customer = relationship('Customer') - - __table_args__ = ( - Index('unique_charge_sub', plan_id, customer_id, - postgresql_where=should_renew == True, - unique=True), - ) - - - @classmethod - def create(cls, customer, plan, coupon=None): - # Coupon passed - if coupon and not coupon.can_use(customer): - raise ValueError( - 'Customer cannot use this coupon. Because either it was ' - 'over redeemed') - subscription = cls.query.filter( - cls.customer == customer, - cls.plan == plan - ).first() - # Coupon not passed, used existing coupon - if subscription and not coupon and subscription.coupon: - coupon = subscription.coupon.can_use( - customer, - ignore_expiration=True) - - subscription = subscription or cls( - customer=customer, plan=plan, coupon=coupon) - subscription.should_renew = True - subscription.is_enrolled = True - cls.session.add(subscription) - return subscription - - @property - def current_invoice(self): - """ - Returns the current invoice of the customer. There can only be one - invoice outstanding per customer ChargePlan - """ - from billy.models import ChargePlanInvoice - - return self.invoices.filter( - ChargePlanInvoice.end_dt > datetime.utcnow()).first() - - def cancel(self): - from billy.models import ChargePlanInvoice - - self.is_enrolled = False - self.should_renew = False - ChargePlanInvoice.prorate_last(self.customer, self.plan) - return self - - - def generate_next_invoice(self): - """ - Rollover the invoice if the next invoice is not already there. - """ - from billy.models import ChargePlanInvoice - - customer = self.customer - plan = self.plan - if self.current_invoice: - return self.current_invoice - last_invoice = self.invoices.order_by( - ChargePlanInvoice.end_dt.desc()).first() - sub = plan.subscribe( - customer=customer, - quantity=last_invoice.quantity, - charge_at_period_end=last_invoice.charge_at_period_end, - start_dt=last_invoice.end_dt) - return sub.current_invoice - - - @classmethod - def generate_all_invoices(cls): - """ - Generate the next invoice for all invoices that need to be generated - """ - from billy.models import ChargePlanInvoice - - now = datetime.utcnow() - needs_invoice_generation = ChargeSubscription.query.outerjoin( - ChargePlanInvoice, - ChargePlanInvoice.end_dt >= now).filter( - ChargePlanInvoice.id == None).all() - - for subscription in needs_invoice_generation: - subscription.generate_next_invoice() - return len(needs_invoice_generation) diff --git a/billy/models/charge/transaction.py b/billy/models/charge/transaction.py deleted file mode 100644 index 59b7b84..0000000 --- a/billy/models/charge/transaction.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy import Column, Unicode, ForeignKey, Integer -from sqlalchemy.orm import relationship - -from billy.models import Base -from billy.utils.models import uuid_factory, Enum - - -ChargeTransactionStatus = Enum('PENDING', 'SENT', 'ERROR', - name='charge_plan_transaction_status') - - -class ChargeTransaction(Base): - __tablename__ = "charge_transactions" - - id = Column(Unicode, primary_key=True, default=uuid_factory('PAT')) - customer_id = Column(Unicode, - ForeignKey('customers.id', ondelete='cascade'), - nullable=False) - processor_txn_id = Column(Unicode, nullable=False) - amount_cents = Column(Integer, nullable=False) - status = Column(ChargeTransactionStatus, nullable=False) - invoice_id = Column(Unicode, ForeignKey('charge_plan_invoices.id'), - nullable=False) - - @classmethod - def create(cls, customer, amount_cents): - transaction = cls( - customer=customer, - amount_cents=amount_cents, - status=ChargeTransactionStatus.PENDING - ) - try: - processor_txn_id = transaction.customer.company.processor.create_charge( - transaction.customer.processor_id, transaction.amount_cents) - transaction.status = ChargeTransactionStatus.SENT - transaction.processor_txn_id = processor_txn_id - cls.session.add(transaction) - except: - transaction.status = ChargeTransactionStatus.ERROR - cls.session.add(transaction) - transaction.session.commit() - raise - return transaction \ No newline at end of file diff --git a/billy/models/company.py b/billy/models/company.py index d9dafe7..a8c1f5e 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -1,179 +1,86 @@ from __future__ import unicode_literals +import logging -from sqlalchemy import Unicode, Column, Enum, Boolean, ForeignKey -from sqlalchemy.orm import relationship, backref +from billy.models import tables +from billy.utils.generic import make_guid +from billy.utils.generic import make_api_key -from billy.models import Base, ProcessorType, PayoutPlan, ChargePlan, Coupon, Customer -from billy.utils.models import api_key_factory, uuid_factory -from .processor import processor_map +class CompanyModel(object): -class Company(Base): - __tablename__ = 'companies' + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session - id = Column(Unicode, primary_key=True, default=uuid_factory('CP')) + def get(self, guid, raise_error=False, ignore_deleted=True): + """Find a company by guid and return it - #: The processor to use for this company - processor_type = Column(ProcessorType, nullable=False) - - #: The credentials/api key that works with the company - processor_credential = Column(Unicode, nullable=False, unique=True) - - #: The id of this company with the processor - processor_company_id = Column(Unicode, nullable=False, unique=True) - - #: Deletion is supported only on test companies with this flag set to true - is_test = Column(Boolean, default=True) - - # Todo: make this a separate table - #: Api key for billy-api - api_key = Column(Unicode, nullable=False, default=api_key_factory()) - - coupons = relationship('Coupon', backref='company', lazy='dynamic', - cascade='delete', ) - customers = relationship('Customer', backref='company', cascade='delete') - charge_plans = relationship( - 'ChargePlan', backref='company', lazy='dynamic', - cascade='delete, delete-orphan') - payout_plans = relationship( - 'PayoutPlan', backref='company', lazy='dynamic', - cascade='delete, delete-orphan') - - - - - @classmethod - def create(cls, processor_type, processor_credential, - is_test=True, **kwargs): - """ - Creates a company + :param guid: The guild of company to get + :param raise_error: Raise KeyError when cannot find one """ + query = ( + self.session.query(tables.Company) + .filter_by(guid=guid) + .filter_by(deleted=not ignore_deleted) + .first() + ) + if raise_error and query is None: + raise KeyError('No such company {}'.format(guid)) + return query - # Todo Some sort of check api_key thingy. - processor_class = processor_map[ - processor_type.upper()](processor_credential) - processor_company_id = processor_class.get_company_id() - company = cls( - processor_type=processor_type.upper(), - processor_credential=processor_credential, - processor_company_id=processor_company_id, - is_test=is_test, **kwargs) - cls.session.add(company) - return company + def get_by_api_key(self, api_key, raise_error=False, ignore_deleted=True): + """Get a company by its API key - def change_processor_credential(self, processor_credential): """ - Updates the company's processor credentials - :param processor_credential: The new credentials - :return: the updated Company object - :raise: ValueError if the processor_company_id doesn't match the one - associated with the new api key - """ - processor_company_id = self.processor.get_company( - processor_credential) - if not processor_company_id == self.processor_company_id: - raise ValueError( - 'New API key does not match company ID with models.processor') - else: - self.processor_credential = processor_credential - return self - - def create_customer(self, your_id, processor_id): - customer = Customer( - your_id=your_id, - processor_id=processor_id, - company=self + query = ( + self.session.query(tables.Company) + .filter_by(api_key=api_key) + .filter_by(deleted=not ignore_deleted) + .first() ) - self.session.add(customer) - return customer - - def create_coupon(self, your_id, name, price_off_cents, - percent_off_int, max_redeem, repeating, expire_at=None): - """ - Create a coupon under the company - :param your_id: The ID you use to identify the coupon in your database - :param name: A name for the coupon for display purposes - :param price_off_cents: The price off in cents - :param percent_off_int: The percent off (0-100) - :param max_redeem: The maximum number of different subscriptions that - can redeem the coupon -1 for unlimited or int - :param repeating: How many invoices can this coupon be used for each - customer? -1 for unlimited or int - :param expire_at: When should the coupon expire? - :return: A Coupon object - """ - coupon= Coupon( - your_id=your_id, - company=self, - name=name, - price_off_cents=price_off_cents, - percent_off_int=percent_off_int, - max_redeem=max_redeem, - repeating=repeating, - expire_at=expire_at) - self.session.add(coupon) - - return coupon + if raise_error and query is None: + raise KeyError('No such company with API key {}'.format(api_key)) + return query + def create(self, processor_key, name=None): + """Create a company and return its id - def create_charge_plan(self, your_id, name, price_cents, - plan_interval, trial_interval): """ - Creates a charge plan under the company - :param your_id: A unique ID you will use to identify this plan i.e - STARTER_PLAN - :param name: A display name for the plan - :param price_cents: The price in cents to charge th customer on each - interval - :param plan_interval: How often does the plan recur? (weekly, monthly) - This is a RelativeDelta object. - :param trial_interval: The initial interval for the trial before - charging the customer. RelativeDelta object. - :return: A ChargePlan object - """ - plan = ChargePlan( - your_id=your_id, - company=self, - name=name, - price_cents=price_cents, - plan_interval=plan_interval, - trial_interval=trial_interval + now = tables.now_func() + company = tables.Company( + guid='CP' + make_guid(), + processor_key=processor_key, + api_key=make_api_key(), + name=name, + created_at=now, + updated_at=now, ) - self.session.add(plan) - return plan + self.session.add(company) + self.session.flush() + return company.guid - def create_payout_plan(self, your_id, name, balance_to_keep_cents, - payout_interval): - """ - Creates a payout plan under the company - :param your_id: What you identify this payout plan as. e.g MY_PAYOUT - :param name: A display name for the payout - :param balance_to_keep_cents: Balance to keep after the payout, this is - how payout amounts are determined balance - balance_to_keep = payout_amount - :param payout_interval: How often should this payout be conducted? - A relative delta object - :return: A PayoutPlan object - """ - payout = PayoutPlan( - your_id=your_id, - company=self, - name=name, - balance_to_keep_cents=balance_to_keep_cents, - payout_interval=payout_interval) - self.session.add(payout) - return payout + def update(self, guid, **kwargs): + """Update a company - def delete(self, force=False): - if not self.is_test and not force: - raise Exception('Can only delete test marketplaces without ' - 'force set to true.') - self.session.delete(self) - self.session.commit() - - @property - def processor(self): """ - Get an instantiated processor for the company i.e DummyProcessor - :return: An instantiated ProcessorClass + company = self.get(guid, raise_error=True) + now = tables.now_func() + company.updated_at = now + for key in ['name', 'processor_key', 'api_key']: + if key not in kwargs: + continue + value = kwargs.pop(key) + setattr(company, key, value) + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(company) + self.session.flush() + + def delete(self, guid): + """Delete a company + """ - return processor_map[self.processor_type](self.processor_credential) + company = self.get(guid, raise_error=True) + company.deleted = True + self.session.add(company) + self.session.flush() diff --git a/billy/models/coupons.py b/billy/models/coupons.py deleted file mode 100644 index 3fb2ced..0000000 --- a/billy/models/coupons.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from sqlalchemy import (Boolean, Column, DateTime, Integer, ForeignKey, - Unicode, UniqueConstraint, CheckConstraint) -from sqlalchemy.orm import relationship - -from billy.models import Base, ChargeSubscription, ChargePlanInvoice -from billy.utils.models import uuid_factory - - -class Coupon(Base): - __tablename__ = 'coupons' - - id = Column(Unicode, primary_key=True, default=uuid_factory('CU')) - your_id = Column(Unicode, nullable=False) - company_id = Column(Unicode, ForeignKey('companies.id', ondelete='cascade'), - nullable=False) - name = Column(Unicode, nullable=False) - price_off_cents = Column(Integer, CheckConstraint('price_off_cents >= 0')) - percent_off_int = Column(Integer, CheckConstraint( - 'percent_off_int >= 0 OR percent_off_int <= 100')) - expire_at = Column(DateTime) - max_redeem = Column(Integer, - CheckConstraint('max_redeem = -1 OR max_redeem >= 0')) - repeating = Column(Integer, - CheckConstraint('repeating = -1 OR repeating >= 0')) - disabled_at = Column(DateTime) - - charge_subscriptions = relationship('ChargeSubscription', backref='coupon', - lazy='dynamic') - charge_invoices = relationship('ChargePlanInvoice', backref='coupon', - lazy='dynamic') - - __table_args__ = ( - UniqueConstraint(your_id, company_id, - name='coupon_id_group_unique'), - ) - - def update(self, new_name=None, - new_max_redeem=None, new_expire_at=None, new_repeating=None): - """ - Updates the coupon with new information provided. - :param new_name: A display name for the coupon - :param new_max_redeem: The number of unique users that can redeem - this coupon - :param new_expire_at: Datetime in which after the coupon will no longer - work - :param new_repeating: The maximum number of invoices it applies to. - -1 for all/forever - :returns: Self - """ - if new_name: - self.name = new_name - if new_max_redeem: - self.max_redeem = new_max_redeem - if new_expire_at: - self.expire_at = new_expire_at - if new_repeating: - self.repeating = new_repeating - return self - - def disable(self): - """ - Deletes the coupon. Coupons are not deleted from the database, - but are instead marked as inactive so no - new users can be added. Everyone currently on the coupon remain on the - plan - """ - self.active = False - self.disabled_at = datetime.utcnow() - return self - - - def can_use(self, customer, ignore_expiration=False): - now = datetime.utcnow() - sub_count = ChargeSubscription.query.filter( - ChargeSubscription.coupon == self).count() - invoice_count = ChargePlanInvoice.query.join(ChargeSubscription).filter( - ChargePlanInvoice.coupon == self, - ChargeSubscription.customer == customer).count() - if not ignore_expiration and self.expire_at and self.expire_at < now: - return False - if self.max_redeem != -1 and self.max_redeem <= sub_count: - return False - if self.repeating != -1 and self.repeating <= invoice_count: - return False - return self - - - @property - def count_customers(self): - """ - The number of unique customers that are using the coupon - """ - return self.customer.count() diff --git a/billy/models/customer.py b/billy/models/customer.py new file mode 100644 index 0000000..8fe6a9b --- /dev/null +++ b/billy/models/customer.py @@ -0,0 +1,74 @@ +from __future__ import unicode_literals +import logging + +from billy.models import tables +from billy.utils.generic import make_guid + + +class CustomerModel(object): + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get(self, guid, raise_error=False, ignore_deleted=True): + """Find a customer by guid and return it + + :param guid: The guild of customer to get + :param raise_error: Raise KeyError when cannot find one + """ + query = ( + self.session.query(tables.Customer) + .filter_by(guid=guid) + .filter_by(deleted=(not ignore_deleted)) + .first() + ) + if raise_error and query is None: + raise KeyError('No such customer {}'.format(guid)) + return query + + def create( + self, + company_guid, + external_id=None + ): + """Create a customer and return its id + + """ + now = tables.now_func() + customer = tables.Customer( + guid='CU' + make_guid(), + company_guid=company_guid, + external_id=external_id, + created_at=now, + updated_at=now, + ) + self.session.add(customer) + self.session.flush() + return customer.guid + + def update(self, guid, **kwargs): + """Update a customer + + """ + customer = self.get(guid, raise_error=True) + now = tables.now_func() + customer.updated_at = now + for key in ['external_id']: + if key not in kwargs: + continue + value = kwargs.pop(key) + setattr(customer, key, value) + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(customer) + self.session.flush() + + def delete(self, guid): + """Delete a customer + + """ + customer = self.get(guid, raise_error=True) + customer.deleted = True + self.session.add(customer) + self.session.flush() diff --git a/billy/models/customers.py b/billy/models/customers.py deleted file mode 100644 index 7798eff..0000000 --- a/billy/models/customers.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from sqlalchemy import Column, Unicode, DateTime, Integer -from sqlalchemy.schema import ForeignKey, UniqueConstraint -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.orm import relationship - -from billy.models import Base, ChargeSubscription, PayoutSubscription -from billy.utils.models import uuid_factory - - -class Customer(Base): - __tablename__ = 'customers' - - id = Column(Unicode, primary_key=True, default=uuid_factory('CU')) - company_id = Column(Unicode, ForeignKey('companies.id', ondelete='cascade'), - nullable=False) - your_id = Column(Unicode, nullable=False) - processor_id = Column(Unicode, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow) - - charge_subscriptions = relationship('ChargeSubscription', - cascade='delete', lazy='dynamic') - charge_invoices = association_proxy('charge_subscriptions', 'invoices') - - charge_transactions = relationship('ChargeTransaction', - backref='customer', cascade='delete', - lazy='dynamic') - - payout_subscriptions = relationship('PayoutSubscription', - backref='customer', - cascade='delete', lazy='dynamic') - payout_invoices = association_proxy('payout_subscriptions', 'invoices') - - payout_transactions = relationship('PayoutTransaction', - backref='customer', cascade='delete', - lazy='dynamic') - - __table_args__ = ( - UniqueConstraint( - your_id, company_id, name='yourid_company_unique'), - ) - - - @property - def charge_subscriptions(self): - return ChargeSubscription.query.filter( - ChargeSubscription.customer == self, - ChargeSubscription.is_enrolled == True).all() - - - @property - def payout_subscriptions(self): - return PayoutSubscription.query.filter( - PayoutSubscription.customer == self, - ChargeSubscription.is_active == True).all() - - - diff --git a/billy/models/payout/invoice.py b/billy/models/payout/invoice.py deleted file mode 100644 index 1a46343..0000000 --- a/billy/models/payout/invoice.py +++ /dev/null @@ -1,118 +0,0 @@ -from datetime import datetime - -from sqlalchemy import (Column, Unicode, ForeignKey, DateTime, Boolean, - Integer, CheckConstraint) -from sqlalchemy.orm import relationship, backref - -from billy.models import Base, PayoutSubscription -from billy import settings -from billy.utils.models import uuid_factory - - -class PayoutPlanInvoice(Base): - __tablename__ = 'payout_invoices' - - id = Column(Unicode, primary_key=True, default=uuid_factory('POI')) - subscription_id = Column(Unicode, ForeignKey('payout_subscription.id', - ondelete='cascade'), - nullable=False) - payout_date = Column(DateTime) - balance_to_keep_cents = Column(Integer, CheckConstraint( - 'balance_to_keep_cents >= 0')) - amount_payed_out = Column(Integer) - completed = Column(Boolean, default=False) - queue_rollover = Column(Boolean, default=False) - balance_at_exec = Column(Integer, - nullable=True) - transaction_id = Column(Unicode, ForeignKey('payout_transactions.id')) - attempts_made = Column(Integer, CheckConstraint('attempts_made >= 0'), - default=0) - - subscription = relationship('PayoutSubscription', - backref=backref('invoices', lazy='dynamic', - cascade='delete'), - ) - - @classmethod - def create(cls, subscription, - payout_date, balanced_to_keep_cents): - invoice = cls( - subscription=subscription, - payout_date=payout_date, - balance_to_keep_cents=balanced_to_keep_cents, - ) - cls.session.add(invoice) - return invoice - - @classmethod - def retrieve(cls, customer, payout, active_only=False, last_only=False): - # Todo can probably be cleaner - query = PayoutSubscription.query.filter( - PayoutSubscription.customer == customer, - PayoutSubscription.payout == payout) - if active_only: - query = query.filter(PayoutSubscription.is_active == True) - subscription = query.first() - if subscription and last_only: - last = None - for invoice in subscription.invoices: - if invoice.payout_date >= datetime.utcnow(): - last = invoice - break - return last - return subscription.invoices - - def generate_next(self): - self.queue_rollover = False - PayoutSubscription.subscribe(self.subscription.customer, - self.subscription.payout, - first_now=False, - start_dt=self.payout_date) - - @classmethod - def generate_all(cls): - needs_generation = cls.query.join(PayoutSubscription).filter( - cls.queue_rollover == True, - PayoutSubscription.is_active == True).all() - for invoice in needs_generation: - invoice.generate_next() - - - @classmethod - def settle_all(cls): - now = datetime.utcnow() - needs_settling = cls.query.filter(cls.payout_date <= now, - cls.completed == False).all() - for invoice in needs_settling: - if len(settings.RETRY_DELAY_PAYOUT) < invoice.attempts_made: - invoice.subscription.is_active = False - else: - retry_delay = sum( - settings.RETRY_DELAY_PAYOUT[:invoice.attempts_made]) - when_to_payout = invoice.payout_date + retry_delay - if when_to_payout <= now: - invoice.settle() - return len(needs_settling) - - def settle(self): - from models import PayoutTransaction - - transactor = self.company.processor - current_balance = transactor.check_balance( - self.subscription.customer.processor_id) - payout_amount = current_balance - self.balance_to_keep_cents - transaction = PayoutTransaction.create( - self.subscription.customer.processor_id, payout_amount) - try: - transaction.execute() - self.transaction = transaction - self.balance_at_exec = current_balance - self.amount_payed_out = payout_amount - self.completed = True - self.queue_rollover = True - except Exception, e: - self.attempts_made += 1 - self.session.commit() - raise e - return self - diff --git a/billy/models/payout/plan.py b/billy/models/payout/plan.py deleted file mode 100644 index 10f7bae..0000000 --- a/billy/models/payout/plan.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from sqlalchemy import (Column, Unicode, Integer, Boolean, - ForeignKey, UniqueConstraint, CheckConstraint) -from sqlalchemy.orm import relationship - -from billy.models import Base, PayoutPlanInvoice, PayoutSubscription -from billy.models.base import RelativeDelta -from billy.utils.models import uuid_factory - - -class PayoutPlan(Base): - __tablename__ = 'payout_plans' - - id = Column(Unicode, primary_key=True, default=uuid_factory('POP')) - your_id = Column(Unicode, nullable=False) - company_id = Column(Unicode, ForeignKey('companies.id', ondelete='cascade'), - nullable=False) - name = Column(Unicode, nullable=False) - balance_to_keep_cents = Column(Integer, - CheckConstraint('balance_to_keep_cents >= 0' - ), nullable=False) - is_active = Column(Boolean, default=True) - payout_interval = Column(RelativeDelta, nullable=False) - - subscriptions = relationship('PayoutSubscription', backref='payout', - cascade='delete, delete-orphan') - - __table_args__ = (UniqueConstraint(your_id, company_id, - name='payout_id_group_unique'), - ) - - def disable(self): - """ - Disables a payout plan. Does not effect current subscribers. - """ - self.is_active = False - self.disabled_at = datetime.utcnow() - return self - - def subscribe(self, customer, first_now=False, start_dt=None): - first_charge = start_dt or datetime.utcnow() - balance_to_keep_cents = self.balance_to_keep_cents - if not first_now: - first_charge += self.payout_interval - subscription = PayoutSubscription.create(customer, self) - invoice = PayoutPlanInvoice.create(subscription, - first_charge, - balance_to_keep_cents) - self.session.add(invoice) - return subscription \ No newline at end of file diff --git a/billy/models/payout/subscription.py b/billy/models/payout/subscription.py deleted file mode 100644 index f700e53..0000000 --- a/billy/models/payout/subscription.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy import Column, Unicode, ForeignKey, DateTime, Boolean, Index -from sqlalchemy.orm import relationship - -from billy.models import Base -from billy.utils.models import uuid_factory - - -class PayoutSubscription(Base): - __tablename__ = 'payout_subscription' - - id = Column(Unicode, primary_key=True, default=uuid_factory('PS')) - customer_id = Column(Unicode, - ForeignKey('customers.id', ondelete='cascade'), - nullable=False) - payout_id = Column(Unicode, ForeignKey('payout_plans.id'), nullable=False) - is_active = Column(Boolean, default=True) - - customer = relationship('Customer') - - __table_args__ = ( - Index('unique_payout_sub', payout_id, customer_id, - postgresql_where=is_active == True, - unique=True), - ) - - @classmethod - def create(cls, customer, payout): - result = cls.query.filter( - cls.customer == customer, - cls.payout == payout).first() - result = result or cls( - customer=customer, payout=payout, - # Todo Temp since default not working for some reason - id=uuid_factory('PLL')()) - result.is_active = True - cls.session.add(result) - return result - - def cancel(self, cancel_scheduled=False): - from billy.models import PayoutPlanInvoice - - self.is_active = False - if cancel_scheduled: - in_process = self.invoices.filter( - PayoutPlanInvoice.completed == False).first() - if in_process: - in_process.completed = True - return self diff --git a/billy/models/payout/transaction.py b/billy/models/payout/transaction.py deleted file mode 100644 index 756a43f..0000000 --- a/billy/models/payout/transaction.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy import Column, Unicode, ForeignKey, Integer -from sqlalchemy.orm import relationship - -from billy.models import Base -from billy.utils.models import uuid_factory, Enum - -PayoutTransactionStatus = Enum('PENDING', 'SENT', 'ERROR', - name='payout_plan_transaction_status') - - -class PayoutTransaction(Base): - __tablename__ = 'payout_transactions' - - id = Column(Unicode, primary_key=True, default=uuid_factory('POT')) - customer_id = Column(Unicode, - ForeignKey('customers.id', ondelete='cascade'), - nullable=False) - processor_txn_id = Column(Unicode, nullable=False) - amount_cents = Column(Integer, nullable=False) - status = Column(PayoutTransactionStatus, nullable=False) - - invoices = relationship('PayoutPlanInvoice', - backref='transaction', cascade='delete') - - - @classmethod - def create(cls, customer, amount_cents): - transaction = cls( - customer=customer, - amount_cents=amount_cents, - status=PayoutTransactionStatus.PENDING - ) - try: - processor_txn_id = transaction.customer.company.processor.make_payout( - transaction.customer.processor_id, transaction.amount_cents) - transaction.status = PayoutTransactionStatus.SENT - transaction.processor_txn_id = processor_txn_id - cls.session.add(transaction) - except: - transaction.status = PayoutTransactionStatus.ERROR - cls.session.add(transaction) - transaction.session.commit() - raise - return transaction \ No newline at end of file diff --git a/billy/models/plan.py b/billy/models/plan.py new file mode 100644 index 0000000..33eadb1 --- /dev/null +++ b/billy/models/plan.py @@ -0,0 +1,118 @@ +from __future__ import unicode_literals +import logging + +from billy.models import tables +from billy.utils.generic import make_guid + + +class PlanModel(object): + + #: Daily frequency + FREQ_DAILY = 0 + #: Weekly frequency + FREQ_WEEKLY = 1 + #: Monthly frequency + FREQ_MONTHLY = 2 + #: Annually frequency + FREQ_YEARLY = 3 + + FREQ_ALL = [ + FREQ_DAILY, + FREQ_WEEKLY, + FREQ_MONTHLY, + FREQ_YEARLY, + ] + + #: Charging type plan + TYPE_CHARGE = 0 + #: Paying out type plan + TYPE_PAYOUT = 1 + + TYPE_ALL = [ + TYPE_CHARGE, + TYPE_PAYOUT, + ] + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get(self, guid, raise_error=False, ignore_deleted=True): + """Find a plan by guid and return it + + :param guid: The guild of plan to get + :param raise_error: Raise KeyError when cannot find one + """ + query = ( + self.session.query(tables.Plan) + .filter_by(guid=guid) + .filter_by(deleted=not ignore_deleted) + .first() + ) + if raise_error and query is None: + raise KeyError('No such plan {}'.format(guid)) + return query + + def create( + self, + company_guid, + plan_type, + amount, + frequency, + interval=1, + external_id=None, + name=None, + description=None, + ): + """Create a plan and return its ID + + """ + if plan_type not in self.TYPE_ALL: + raise ValueError('Invalid plan_type {}'.format(plan_type)) + if frequency not in self.FREQ_ALL: + raise ValueError('Invalid frequency {}'.format(frequency)) + if interval < 1: + raise ValueError('Interval can only be >= 1') + now = tables.now_func() + plan = tables.Plan( + guid='PL' + make_guid(), + company_guid=company_guid, + plan_type=plan_type, + amount=amount, + frequency=frequency, + interval=interval, + external_id=external_id, + name=name, + description=description, + updated_at=now, + created_at=now, + ) + self.session.add(plan) + self.session.flush() + return plan.guid + + def update(self, guid, **kwargs): + """Update a plan + + """ + plan = self.get(guid, raise_error=True) + now = tables.now_func() + plan.updated_at = now + for key in ['name', 'external_id', 'description']: + if key not in kwargs: + continue + value = kwargs.pop(key) + setattr(plan, key, value) + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(plan) + self.session.flush() + + def delete(self, guid): + """Delete a plan + + """ + plan = self.get(guid, raise_error=True) + plan.deleted = True + self.session.add(plan) + self.session.flush() diff --git a/billy/models/processor/__init__.py b/billy/models/processor/__init__.py deleted file mode 100644 index 13abdc7..0000000 --- a/billy/models/processor/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import unicode_literals - -from balanced import BalancedProcessor -from billy.utils.models import Enum -from .dummy import DummyProcessor - - -ProcessorType = Enum('BALANCED', 'DUMMY', name='processor_type') - -processor_map = { - ProcessorType.BALANCED: BalancedProcessor, - ProcessorType.DUMMY: DummyProcessor, -} diff --git a/billy/models/processor/balanced.py b/billy/models/processor/balanced.py deleted file mode 100644 index 7dc0473..0000000 --- a/billy/models/processor/balanced.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import unicode_literals -from hashlib import md5 -import random - -from billy.utils.models import uuid_factory - - -class BalancedProcessor(object): - def __init__(self, credential): - self.credential = credential - - def get_company_id(self): - """ - Returns the id of the company with the models.processor, this is a form of - authentication - """ - hash = md5() - hash.update(self.credential) - return hash.hexdigest() - - def can_add_customer(self, customer_id): - """ - Checks if customer exists and has a funding instrument - """ - return True - - def check_balance(self, customer_id): - """ - Returns balance - """ - return random.randint(100000, 500000) - - def create_charge(self, customer, amount_cents): - """ - Returns a transaction identifier or raises error - """ - return uuid_factory('CHDUMMY')() - - def make_payout(self, customer, amount_cents): - """ - Returns a transaction identifier or raises error. - """ - return uuid_factory('PODUMMY')() diff --git a/billy/models/processor/dummy.py b/billy/models/processor/dummy.py deleted file mode 100644 index 06a8485..0000000 --- a/billy/models/processor/dummy.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import unicode_literals -from hashlib import md5 -import random - -from billy.utils.models import uuid_factory - - -class DummyProcessor(object): - def __init__(self, credential): - self.credential = credential - - def get_company_id(self): - """ - Returns the id of the company with the models.processor, this is a form of - authentication - """ - hash = md5() - hash.update(self.credential) - return hash.hexdigest() - - def can_add_customer(self, customer_id): - """ - Checks if customer exists and has a funding instrument - """ - return True - - def check_balance(self, customer_id): - """ - Returns balance - """ - return random.randint(100000, 500000) - - def create_charge(self, customer, amount_cents): - """ - Returns a transaction identifier or raises error - """ - return uuid_factory('CHDUMMY')() - - def make_payout(self, customer, amount_cents): - """ - Returns a transaction identifier or raises error. - """ - return uuid_factory('PODUMMY')() diff --git a/billy/models/charge/__init__.py b/billy/models/processors/__init__.py similarity index 100% rename from billy/models/charge/__init__.py rename to billy/models/processors/__init__.py diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py new file mode 100644 index 0000000..4976357 --- /dev/null +++ b/billy/models/processors/balanced_payments.py @@ -0,0 +1,170 @@ +from __future__ import unicode_literals +import logging + +import balanced + +from billy.models.processors.base import PaymentProcessor + + +class BalancedProcessor(PaymentProcessor): + + def __init__( + self, + customer_cls=balanced.Customer, + debit_cls=balanced.Debit, + credit_cls=balanced.Credit, + refund_cls=balanced.Refund, + logger=None, + ): + self.logger = logger or logging.getLogger(__name__) + self.customer_cls = customer_cls + self.debit_cls = debit_cls + self.credit_cls = credit_cls + self.refund_cls = refund_cls + + def _to_cent(self, amount): + cent = amount * 100 + cent = int(cent) + return cent + + def create_customer(self, customer): + api_key = customer.company.processor_key + balanced.configure(api_key) + + self.logger.debug('Creating Balanced customer for %s', customer.guid) + record = self.customer_cls(**{ + 'meta.billy_customer_guid': customer.guid, + }).save() + self.logger.info('Created Balanced customer for %s', customer.guid) + return record.uri + + def prepare_customer(self, customer, payment_uri=None): + api_key = customer.company.processor_key + balanced.configure(api_key) + + self.logger.debug('Preparing customer %s with payment_uri=%s', + customer.guid, payment_uri) + # when payment_uri is None, it means we are going to use the + # default funding instrument, just return + if payment_uri is None: + return + # get balanced customer record + external_id = customer.external_id + balanced_customer = self.customer_cls.find(external_id) + # TODO: use a better way to determine type of URI? + if '/bank_accounts/' in payment_uri: + self.logger.debug('Adding bank account %s to %s', + payment_uri, customer.guid) + balanced_customer.add_bank_account(payment_uri) + self.logger.info('Added bank account %s to %s', + payment_uri, customer.guid) + elif '/cards/' in payment_uri: + self.logger.debug('Adding credit card %s to %s', + payment_uri, customer.guid) + balanced_customer.add_card(payment_uri) + self.logger.info('Added credit card %s to %s', + payment_uri, customer.guid) + else: + raise ValueError('Invalid payment_uri {}'.format(payment_uri)) + + def _do_transaction( + self, + transaction, + resource_cls, + method_name, + extra_kwargs + ): + api_key = transaction.subscription.plan.company.processor_key + balanced.configure(api_key) + # make sure we won't duplicate the transaction + try: + record = ( + resource_cls.query + .filter(**{'meta.billy.transaction_guid': transaction.guid}) + .one() + ) + except balanced.exc.NoResultFound: + record = None + # We already have a record there in Balanced, this means we once did + # transaction, however, we failed to update database. No need to do + # it again, just return the id + if record is not None: + self.logger.warn('Balanced transaction record for %s already ' + 'exist', transaction.guid) + return record.uri + + # TODO: handle error here + # get balanced customer record + external_id = transaction.subscription.customer.external_id + balanced_customer = self.customer_cls.find(external_id) + + # prepare arguments + kwargs = dict( + amount=self._to_cent(transaction.amount), + description=( + 'Generated by Billy from subscription {}, scheduled_at={}' + .format(transaction.subscription.guid, transaction.scheduled_at) + ), + meta={'billy.transaction_guid': transaction.guid}, + ) + kwargs.update(extra_kwargs) + + method = getattr(balanced_customer, method_name) + self.logger.debug('Calling %s with args %s', method.__name__, kwargs) + record = method(**kwargs) + self.logger.info('Called %s with args %s', method.__name__, kwargs) + return record.uri + + def charge(self, transaction): + extra_kwargs = {} + if transaction.payment_uri is not None: + extra_kwargs['source_uri'] = transaction.payment_uri + return self._do_transaction( + transaction=transaction, + resource_cls=self.debit_cls, + method_name='debit', + extra_kwargs=extra_kwargs, + ) + + def payout(self, transaction): + extra_kwargs = {} + if transaction.payment_uri is not None: + extra_kwargs['destination_uri'] = transaction.payment_uri + return self._do_transaction( + transaction=transaction, + resource_cls=self.credit_cls, + method_name='credit', + extra_kwargs=extra_kwargs, + ) + + def refund(self, transaction): + api_key = transaction.subscription.plan.company.processor_key + balanced.configure(api_key) + + # make sure we won't duplicate refund + try: + refund = ( + self.refund_cls.query + .filter(**{'meta.billy.transaction_guid': transaction.guid}) + .one() + ) + except balanced.exc.NoResultFound: + refund = None + if refund is not None: + self.logger.warn('Balanced transaction refund for %s already ' + 'exist', transaction.guid) + return refund.uri + + charge_transaction = transaction.refund_to + debit = self.debit_cls.find(charge_transaction.external_id) + refund = debit.refund( + amount=self._to_cent(transaction.amount), + description=( + 'Generated by Billy from subscription {}, scheduled_at={}' + .format(transaction.subscription.guid, transaction.scheduled_at) + ), + meta={'billy.transaction_guid': transaction.guid}, + ) + self.logger.info('Processed refund transaction %s, amount=%s', + transaction.guid, transaction.amount) + return refund.uri diff --git a/billy/models/processors/base.py b/billy/models/processors/base.py new file mode 100644 index 0000000..d93e7cb --- /dev/null +++ b/billy/models/processors/base.py @@ -0,0 +1,39 @@ +from __future__ import unicode_literals + + +class PaymentProcessor(object): + + def create_customer(self, customer): + """Create the customer record in payment processor + + :param customer: the customer table object + :return: external id of customer from processor + """ + raise NotImplementedError + + def prepare_customer(self, customer, payment_uri=None): + """Prepare customer for transaction, usually this would associate + bank account or credit card to the customer + + :param customer: customer to be prepared + :param payment_uri: payment URI to prepare + """ + raise NotImplementedError + + def charge(self, transaction): + """Charge from a bank acount or credit card + + """ + raise NotImplementedError + + def payout(self, transaction): + """Payout to a account + + """ + raise NotImplementedError + + def refund(self, transaction): + """Refund a transaction + + """ + raise NotImplementedError diff --git a/billy/models/schedule.py b/billy/models/schedule.py new file mode 100644 index 0000000..821ccae --- /dev/null +++ b/billy/models/schedule.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +from dateutil.relativedelta import relativedelta + +from billy.models.plan import PlanModel + + +def next_transaction_datetime(started_at, frequency, period, interval=1): + """Get next transaction datetime from given frequency, started datetime + and period + + :param started_at: the started datetime of the first transaction + :param frequency: the plan frequency + :param period: how many periods has been passed, 0 indicates this is the + first transaction + :param interval: the interval of period, interval 3 with monthly + frequency menas every 3 months + """ + if frequency not in PlanModel.FREQ_ALL: + raise ValueError('Invalid frequency {}'.format(frequency)) + if interval < 1: + raise ValueError('Interval can only be >= 1') + if period == 0: + return started_at + delta = None + if frequency == PlanModel.FREQ_DAILY: + delta = relativedelta(days=period * interval) + elif frequency == PlanModel.FREQ_WEEKLY: + delta = relativedelta(weeks=period * interval) + elif frequency == PlanModel.FREQ_MONTHLY: + delta = relativedelta(months=period * interval) + elif frequency == PlanModel.FREQ_YEARLY: + delta = relativedelta(years=period * interval) + return started_at + delta diff --git a/billy/models/subscription.py b/billy/models/subscription.py new file mode 100644 index 0000000..b512e3b --- /dev/null +++ b/billy/models/subscription.py @@ -0,0 +1,296 @@ +from __future__ import unicode_literals +import logging +import decimal + +from billy.models import tables +from billy.models.plan import PlanModel +from billy.models.transaction import TransactionModel +from billy.models.schedule import next_transaction_datetime +from billy.utils.generic import make_guid +from billy.utils.generic import round_down_cent + + +class SubscriptionCanceledError(RuntimeError): + """This error indicates that the subscription is already canceled, + you cannot cancel a canceled subscription + + """ + + +class SubscriptionModel(object): + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get(self, guid, raise_error=False): + """Find a subscription by guid and return it + + :param guid: The guild of subscription to get + :param raise_error: Raise KeyError when cannot find one + """ + query = ( + self.session.query(tables.Subscription) + .filter_by(guid=guid) + .first() + ) + if raise_error and query is None: + raise KeyError('No such subscription {}'.format(guid)) + return query + + def create( + self, + customer_guid, + plan_guid, + payment_uri=None, + started_at=None, + external_id=None, + amount=None, + ): + """Create a subscription and return its id + + """ + if amount is not None and amount <= 0: + raise ValueError('Amount should be a non-zero postive float number') + now = tables.now_func() + if started_at is None: + started_at = now + elif started_at < now: + raise ValueError('Past started_at time is not allowed') + subscription = tables.Subscription( + guid='SU' + make_guid(), + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + payment_uri=payment_uri, + external_id=external_id, + started_at=started_at, + next_transaction_at=started_at, + created_at=now, + updated_at=now, + ) + self.session.add(subscription) + self.session.flush() + return subscription.guid + + def update(self, guid, **kwargs): + """Update a subscription + + :param guid: the guid of subscription to update + :param external_id: external_id to update + """ + subscription = self.get(guid, raise_error=True) + now = tables.now_func() + subscription.updated_at = now + for key in ['external_id']: + if key not in kwargs: + continue + value = kwargs.pop(key) + setattr(subscription, key, value) + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(subscription) + self.session.flush() + + def cancel(self, guid, prorated_refund=False, refund_amount=None): + """Cancel a subscription + + :param guid: the guid of subscription to cancel + :param prorated_refund: Should we generate a prorated refund + transaction according to remaining time of subscription period? + :param refund_amount: if refund_amount is given, it will be used + to refund customer, you cannot set prorated_refund with + refund_amount + :return: if prorated_refund is True, and the subscription is + refundable, the refund transaction guid will be returned + """ + if prorated_refund and refund_amount is not None: + raise ValueError('You cannot set refund_amount when ' + 'prorated_refund is True') + + tx_model = TransactionModel(self.session) + + subscription = self.get(guid, raise_error=True) + if subscription.canceled: + raise SubscriptionCanceledError('Subscription {} is already ' + 'canceled'.format(guid)) + now = tables.now_func() + subscription.canceled = True + subscription.canceled_at = now + tx_guid = None + + # should we do refund + do_refund = False + # we want to do a prorated refund here, however, if there is no any + # issued transaction, then no need to do a refund, just skip + if ( + (prorated_refund or refund_amount is not None) and + subscription.period + ): + previous_transaction = ( + self.session.query(tables.Transaction) + .filter_by(subscription_guid=subscription.guid) + .order_by(tables.Transaction.scheduled_at.desc()) + .first() + ) + # it is possible the previous transaction is failed or retrying, + # so that we should only refund finished transaction + if previous_transaction.status == TransactionModel.STATUS_DONE: + do_refund = True + + if do_refund: + if prorated_refund: + previous_datetime = previous_transaction.scheduled_at + # the total time delta in the period + total_delta = ( + subscription.next_transaction_at - previous_datetime + ) + total_seconds = decimal.Decimal(total_delta.total_seconds()) + # the passed time so far since last transaction + elapsed_delta = now - previous_datetime + elapsed_seconds = decimal.Decimal(elapsed_delta.total_seconds()) + + # TODO: what about calculate in different granularity here? + # such as day or hour granularity? + rate = 1 - (elapsed_seconds / total_seconds) + amount = previous_transaction.amount * rate + amount = round_down_cent(amount) + else: + amount = round_down_cent(decimal.Decimal(refund_amount)) + if amount > previous_transaction.amount: + raise ValueError('refund_amount cannot be grather than ' + 'subscription amount {}' + .format(previous_transaction.amount)) + + # make sure we will not refund zero dollar + # TODO: or... should we? + if amount: + tx_guid = tx_model.create( + subscription_guid=subscription.guid, + amount=amount, + transaction_type=tx_model.TYPE_REFUND, + scheduled_at=subscription.next_transaction_at, + refund_to_guid=previous_transaction.guid, + ) + + # cancel not done transactions (exclude refund transaction) + Transaction = tables.Transaction + not_done_transactions = ( + self.session.query(Transaction) + .filter_by(subscription_guid=guid) + .filter(Transaction.transaction_type != TransactionModel.TYPE_REFUND) + .filter(Transaction.status.in_([ + tx_model.STATUS_INIT, + tx_model.STATUS_RETRYING, + ])) + ) + not_done_transactions.update(dict( + status=tx_model.STATUS_CANCELED, + updated_at=now, + ), synchronize_session='fetch') + + self.session.add(subscription) + self.session.flush() + return tx_guid + + def yield_transactions(self, subscription_guids=None, now=None): + """Generate new necessary transactions according to subscriptions we + had return guid list + + :param subscription_guids: A list subscription guid to yield + transaction_type from, if None is given, all subscriptions + in the database will be the yielding source + :param now: the current date time to use, now_func() will be used by + default + :return: a generated transaction guid list + """ + from sqlalchemy.sql.expression import not_ + + if now is None: + now = tables.now_func() + + tx_model = TransactionModel(self.session) + Subscription = tables.Subscription + + transaction_guids = [] + + # as we may have multiple new transactions for one subscription to + # process, for example, we didn't run this method for a long while, + # in this case, we need to make sure all transactions are yielded + while True: + # find subscriptions which should yield new transactions + query = ( + self.session.query(Subscription) + .filter(Subscription.next_transaction_at <= now) + .filter(not_(Subscription.canceled)) + ) + if subscription_guids is not None: + query = query.filter(Subscription.guid.in_(subscription_guids)) + subscriptions = query.all() + + # okay, we have no more transaction to process, just break + if not subscriptions: + self.logger.info('No more subscriptions to process') + break + + for subscription in subscriptions: + if subscription.plan.plan_type == PlanModel.TYPE_CHARGE: + transaction_type = tx_model.TYPE_CHARGE + elif subscription.plan.plan_type == PlanModel.TYPE_PAYOUT: + transaction_type = tx_model.TYPE_PAYOUT + else: + raise ValueError('Unknown plan type {} to process' + .format(subscription.plan.plan_type)) + # when amount of subscription is given, we should use it + # instead the one from plan + if subscription.amount is None: + amount = subscription.plan.amount + else: + amount = subscription.amount + type_map = { + tx_model.TYPE_CHARGE: 'charge', + tx_model.TYPE_PAYOUT: 'payout', + } + self.logger.debug( + 'Creating transaction for %s, transaction_type=%s, ' + 'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', + subscription.guid, + type_map[transaction_type], + subscription.payment_uri, + amount, + subscription.next_transaction_at, + subscription.period, + ) + # create the new transaction for this subscription + guid = tx_model.create( + subscription_guid=subscription.guid, + payment_uri=subscription.payment_uri, + amount=amount, + transaction_type=transaction_type, + scheduled_at=subscription.next_transaction_at, + ) + self.logger.info( + 'Created transaction for %s, guid=%s, transaction_type=%s, ' + 'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', + subscription.guid, + guid, + type_map[transaction_type], + subscription.payment_uri, + amount, + subscription.next_transaction_at, + subscription.period, + ) + # advance the next transaction time + subscription.period += 1 + subscription.next_transaction_at = next_transaction_datetime( + started_at=subscription.started_at, + frequency=subscription.plan.frequency, + period=subscription.period, + interval=subscription.plan.interval, + ) + self.session.add(subscription) + self.session.flush() + transaction_guids.append(guid) + + self.session.flush() + return transaction_guids diff --git a/billy/models/tables.py b/billy/models/tables.py new file mode 100644 index 0000000..62bce65 --- /dev/null +++ b/billy/models/tables.py @@ -0,0 +1,258 @@ +from __future__ import unicode_literals + +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import Unicode +from sqlalchemy import UnicodeText +from sqlalchemy import Boolean +from sqlalchemy import DateTime +from sqlalchemy import Numeric +from sqlalchemy.schema import ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.orm import backref +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.sql.expression import func + +DeclarativeBase = declarative_base() + +#: The now function for database relative operation +_now_func = [func.utc_timestamp] + + +def set_now_func(func): + """Replace now function and return the old function + + """ + old = _now_func[0] + _now_func[0] = func + return old + + +def get_now_func(): + """Return current now func + + """ + return _now_func[0] + + +def now_func(): + """Return current datetime + + """ + func = get_now_func() + return func() + + +class Company(DeclarativeBase): + """A Company is basically a user to billy system + + """ + __tablename__ = 'company' + + guid = Column(Unicode(64), primary_key=True) + #: the API key for accessing billy system + api_key = Column(Unicode(64), unique=True, index=True, nullable=False) + #: the processor key (it would be balanced API key if we are using balanced) + processor_key = Column(Unicode(64), index=True, nullable=False) + #: a short optional name of this company + name = Column(Unicode(128)) + #: is this company deleted? + deleted = Column(Boolean, default=False, nullable=False) + #: the created datetime of this company + created_at = Column(DateTime, default=now_func) + #: the updated datetime of this company + updated_at = Column(DateTime, default=now_func) + + #: plans of this company + plans = relationship('Plan', cascade='all, delete-orphan', backref='company') + #: customers of this company + customers = relationship('Customer', cascade='all, delete-orphan', backref='company') + + +class Customer(DeclarativeBase): + """A Customer is a target for charging or payout to + + """ + __tablename__ = 'customer' + + guid = Column(Unicode(64), primary_key=True) + #: the guid of company which owns this customer + company_guid = Column( + Unicode(64), + ForeignKey( + 'company.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + nullable=False, + ) + #: the ID of customer record in payment processing system + external_id = Column(Unicode(128), index=True) + #: is this company deleted? + deleted = Column(Boolean, default=False, nullable=False) + #: the created datetime of this company + created_at = Column(DateTime, default=now_func) + #: the updated datetime of this company + updated_at = Column(DateTime, default=now_func) + + #: subscriptions of this customer + subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='customer') + + +class Plan(DeclarativeBase): + """Plan is a recurring payment schedule, such as a hosting service plan. + + """ + __tablename__ = 'plan' + + guid = Column(Unicode(64), primary_key=True) + #: the guid of company which owns this plan + company_guid = Column( + Unicode(64), + ForeignKey( + 'company.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + nullable=False, + ) + #: what kind of plan it is, 0=charge, 1=payout + plan_type = Column(Integer, nullable=False, index=True) + #: the external ID given by user + external_id = Column(Unicode(128), index=True) + #: a short name of this plan + name = Column(Unicode(128)) + #: a long description of this plan + description = Column(UnicodeText) + #: the amount to bill user + # TODO: make sure how many digi of number we need + # TODO: Fix SQLite doesn't support decimal issue? + amount = Column(Numeric(10, 2), nullable=False) + #: the fequency to bill user, 0=daily, 1=weekly, 2=monthly + frequency = Column(Integer, nullable=False) + #: interval of period, for example, interval 3 with weekly frequency + # means this plan will do transaction every 3 weeks + interval = Column(Integer, nullable=False, default=1) + #: is this plan deleted? + deleted = Column(Boolean, default=False, nullable=False) + #: the created datetime of this plan + created_at = Column(DateTime, default=now_func) + #: the updated datetime of this plan + updated_at = Column(DateTime, default=now_func) + + #: subscriptions of this plan + subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='plan') + + +class Subscription(DeclarativeBase): + """A subscription relationship between Customer and Plan + + """ + __tablename__ = 'subscription' + + guid = Column(Unicode(64), primary_key=True) + #: the guid of customer who subscribes + customer_guid = Column( + Unicode(64), + ForeignKey( + 'customer.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + nullable=False, + ) + #: the guid of plan customer subscribes to + plan_guid = Column( + Unicode(64), + ForeignKey( + 'plan.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + nullable=False, + ) + #: the payment URI to charge/payout, such as bank account or credit card + payment_uri = Column(Unicode(128), index=True) + #: if this amount is not null, the amount of plan will be overwritten + amount = Column(Numeric(10, 2)) + #: the external ID given by user + external_id = Column(Unicode(128), index=True) + #: is this subscription canceled? + canceled = Column(Boolean, default=False, nullable=False) + #: the next datetime to charge or pay out + next_transaction_at = Column(DateTime, nullable=False) + #: how many transaction has been generated + period = Column(Integer, nullable=False, default=0) + #: the started datetime of this subscription + started_at = Column(DateTime, nullable=False) + #: the canceled datetime of this subscription + canceled_at = Column(DateTime, default=None) + #: the created datetime of this subscription + created_at = Column(DateTime, default=now_func) + #: the updated datetime of this subscription + updated_at = Column(DateTime, default=now_func) + + #: transactions of this subscription + transactions = relationship('Transaction', cascade='all, delete-orphan', + backref='subscription') + + +class Transaction(DeclarativeBase): + """A transaction of subscription, typically, this can be a bank charging + or credit card debiting operation. It could also be a refunding or paying + out operation. + + """ + __tablename__ = 'transaction' + + guid = Column(Unicode(64), primary_key=True) + #: the guid of subscription which generated this transaction + subscription_guid = Column( + Unicode(64), + ForeignKey( + 'subscription.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + nullable=False, + ) + #: the guid of target transaction to refund to + refund_to_guid = Column( + Unicode(64), + ForeignKey( + 'transaction.guid', + ondelete='CASCADE', onupdate='CASCADE' + ), + index=True, + ) + #: what type of transaction it is, 0=charge, 1=refund, 2=payout + transaction_type = Column(Integer, index=True, nullable=False) + #: the ID of transaction record in payment processing system + external_id = Column(Unicode(128), index=True) + #: current status of this transaction, could be + # 0=init, 1=retrying, 2=done, 3=failed, 4=canceled + status = Column(Integer, index=True, nullable=False) + #: the amount to do transaction (charge, payout or refund) + amount = Column(Numeric(10, 2), nullable=False) + #: the payment URI + payment_uri = Column(Unicode(128), index=True) + #: count of failure times + failure_count = Column(Integer, default=0) + #: error message when failed + error_message = Column(UnicodeText) + #: the scheduled datetime of this transaction should be processed + scheduled_at = Column(DateTime, default=now_func) + #: the created datetime of this subscription + created_at = Column(DateTime, default=now_func) + #: the updated datetime of this subscription + updated_at = Column(DateTime, default=now_func) + + #: target transaction of refund transaction + refund_to = relationship( + 'Transaction', + cascade='all, delete-orphan', + backref=backref('refund_from', uselist=False), + remote_side=[guid], + uselist=False, + single_parent=True, + ) diff --git a/billy/models/transaction.py b/billy/models/transaction.py new file mode 100644 index 0000000..c4ab52a --- /dev/null +++ b/billy/models/transaction.py @@ -0,0 +1,222 @@ +from __future__ import unicode_literals +import logging + +from billy.models import tables +from billy.utils.generic import make_guid + + +class TransactionModel(object): + + #: charge type transaction + TYPE_CHARGE = 0 + #: refund type transaction + TYPE_REFUND = 1 + #: Paying out type transaction + TYPE_PAYOUT = 2 + + TYPE_ALL = [ + TYPE_CHARGE, + TYPE_REFUND, + TYPE_PAYOUT, + ] + + #: initialized status + STATUS_INIT = 0 + #: we are retrying this transaction + STATUS_RETRYING = 1 + #: this transaction is done + STATUS_DONE = 2 + #: this transaction is failed + STATUS_FAILED = 3 + #: this transaction is canceled + STATUS_CANCELED = 4 + + STATUS_ALL = [ + STATUS_INIT, + STATUS_RETRYING, + STATUS_DONE, + STATUS_FAILED, + STATUS_CANCELED, + ] + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get(self, guid, raise_error=False): + """Find a transaction by guid and return it + + :param guid: The guild of transaction to get + :param raise_error: Raise KeyError when cannot find one + """ + query = ( + self.session.query(tables.Transaction) + .filter_by(guid=guid) + .first() + ) + if raise_error and query is None: + raise KeyError('No such transaction {}'.format(guid)) + return query + + def list_by_company_guid(self, company_guid, offset=None, limit=None): + """Get transactions of a company by given guid + + """ + Transaction = tables.Transaction + Subscription = tables.Subscription + Plan = tables.Plan + query = ( + self.session + .query(Transaction) + .join((Subscription, + Subscription.guid == Transaction.subscription_guid)) + .join((Plan, Plan.guid == Subscription.plan_guid)) + .filter(Plan.company_guid == company_guid) + .order_by(Transaction.created_at.asc()) + ) + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + return query + + def create( + self, + subscription_guid, + transaction_type, + amount, + scheduled_at, + payment_uri=None, + refund_to_guid=None, + ): + """Create a transaction and return its ID + + """ + if transaction_type not in self.TYPE_ALL: + raise ValueError('Invalid transaction_type {}' + .format(transaction_type)) + if refund_to_guid is not None: + if transaction_type != self.TYPE_REFUND: + raise ValueError('refund_to_guid can only be set to a refund ' + 'transaction') + if payment_uri is not None: + raise ValueError('payment_uri cannot be set to a refund ' + 'transaction') + refund_transaction = self.get(refund_to_guid, raise_error=True) + if refund_transaction.transaction_type != self.TYPE_CHARGE: + raise ValueError('Only charge transaction can be refunded') + + now = tables.now_func() + transaction = tables.Transaction( + guid='TX' + make_guid(), + subscription_guid=subscription_guid, + transaction_type=transaction_type, + amount=amount, + payment_uri=payment_uri, + status=self.STATUS_INIT, + scheduled_at=scheduled_at, + refund_to_guid=refund_to_guid, + created_at=now, + updated_at=now, + ) + self.session.add(transaction) + self.session.flush() + return transaction.guid + + def update(self, guid, **kwargs): + """Update a transaction + + """ + transaction = self.get(guid, raise_error=True) + now = tables.now_func() + transaction.updated_at = now + if 'status' in kwargs: + status = kwargs.pop('status') + if status not in self.STATUS_ALL: + raise ValueError('Invalid status {}'.format(status)) + transaction.status = status + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(transaction) + self.session.flush() + + def process_one(self, processor, transaction): + """Process one transaction + + """ + if transaction.status == self.STATUS_DONE: + raise ValueError('Cannot process a finished transaction {}' + .format(transaction.guid)) + self.logger.debug('Processing transaction %s', transaction.guid) + now = tables.now_func() + customer = transaction.subscription.customer + try: + # create customer record in balanced + if customer.external_id is None: + customer_id = processor.create_customer(customer) + customer.external_id = customer_id + self.session.add(customer) + self.session.flush() + + self.logger.info('External customer %s', customer.external_id) + + # prepare customer (add bank account or credit card) + processor.prepare_customer(customer, transaction.payment_uri) + + if transaction.transaction_type == self.TYPE_CHARGE: + method = processor.charge + elif transaction.transaction_type == self.TYPE_PAYOUT: + method = processor.payout + elif transaction.transaction_type == self.TYPE_REFUND: + method = processor.refund + + transaction_id = method(transaction) + # TODO: generate an invoice here? + except (SystemExit, KeyboardInterrupt): + raise + except Exception, e: + transaction.status = self.STATUS_RETRYING + # TODO: provide more expressive error message? + transaction.error_message = unicode(e) + transaction.failure_count += 1 + self.logger.error('Failed to process transaction %s, ' + 'failure_count=%s', + transaction.guid, transaction.failure_count, + exc_info=True) + # TODO: maybe we should limit failure count here? + # such as too many faiure then transit to FAILED status? + transaction.updated_at = now + self.session.add(transaction) + self.session.flush() + return + + transaction.external_id = transaction_id + transaction.status = self.STATUS_DONE + transaction.updated_at = tables.now_func() + self.session.add(transaction) + self.session.flush() + + self.logger.info('Processed transaction %s, status=%s, external_id=%s', + transaction.guid, transaction.status, + transaction.external_id) + + def process_transactions(self, processor, guids=None): + """Process all transactions + + """ + Transaction = tables.Transaction + query = ( + self.session.query(Transaction) + .filter(Transaction.status.in_([ + self.STATUS_INIT, + self.STATUS_RETRYING] + )) + ) + if guids is not None: + query = query.filter(Transaction.guid.in_(guids)) + + processed_transaction_guids = [] + for transaction in query: + self.process_one(processor, transaction) + processed_transaction_guids.append(transaction.guid) + return processed_transaction_guids diff --git a/billy/renderers.py b/billy/renderers.py new file mode 100644 index 0000000..e40c15f --- /dev/null +++ b/billy/renderers.py @@ -0,0 +1,114 @@ +from __future__ import unicode_literals + +from pyramid.renderers import JSON + +from billy.models import tables + + +def company_adapter(company, request): + return dict( + guid=company.guid, + api_key=company.api_key, + created_at=company.created_at.isoformat(), + updated_at=company.updated_at.isoformat(), + ) + + +def customer_adapter(customer, request): + return dict( + guid=customer.guid, + external_id=customer.external_id, + created_at=customer.created_at.isoformat(), + updated_at=customer.updated_at.isoformat(), + company_guid=customer.company_guid, + ) + + +def plan_adapter(plan, request): + from billy.models.plan import PlanModel + type_map = { + PlanModel.TYPE_CHARGE: 'charge', + PlanModel.TYPE_PAYOUT: 'payout', + } + plan_type = type_map[plan.plan_type] + + freq_map = { + PlanModel.FREQ_DAILY: 'daily', + PlanModel.FREQ_WEEKLY: 'weekly', + PlanModel.FREQ_MONTHLY: 'monthly', + PlanModel.FREQ_YEARLY: 'yearly', + } + frequency = freq_map[plan.frequency] + return dict( + guid=plan.guid, + plan_type=plan_type, + frequency=frequency, + amount=str(plan.amount), + interval=plan.interval, + created_at=plan.created_at.isoformat(), + updated_at=plan.updated_at.isoformat(), + company_guid=plan.company_guid, + ) + + +def subscription_adapter(subscription, request): + canceled_at = None + if subscription.canceled_at is not None: + canceled_at = subscription.canceled_at.isoformat() + return dict( + guid=subscription.guid, + amount=str(subscription.amount), + payment_uri=subscription.payment_uri, + period=subscription.period, + canceled=subscription.canceled, + next_transaction_at=subscription.next_transaction_at.isoformat(), + created_at=subscription.created_at.isoformat(), + updated_at=subscription.updated_at.isoformat(), + started_at=subscription.started_at.isoformat(), + canceled_at=canceled_at, + customer_guid=subscription.customer_guid, + plan_guid=subscription.plan_guid, + ) + + +def transaction_adapter(transaction, request): + from billy.models.transaction import TransactionModel + type_map = { + TransactionModel.TYPE_CHARGE: 'charge', + TransactionModel.TYPE_PAYOUT: 'payout', + TransactionModel.TYPE_REFUND: 'refund', + } + transaction_type = type_map[transaction.transaction_type] + + status_map = { + TransactionModel.STATUS_INIT: 'init', + TransactionModel.STATUS_RETRYING: 'retrying', + TransactionModel.STATUS_FAILED: 'failed', + TransactionModel.STATUS_DONE: 'done', + } + status = status_map[transaction.status] + + return dict( + guid=transaction.guid, + transaction_type=transaction_type, + status=status, + amount=str(transaction.amount), + payment_uri=transaction.payment_uri, + external_id=transaction.external_id, + failure_count=transaction.failure_count, + error_message=transaction.error_message, + created_at=transaction.created_at.isoformat(), + updated_at=transaction.updated_at.isoformat(), + scheduled_at=transaction.scheduled_at.isoformat(), + subscription_guid=transaction.subscription_guid, + ) + + +def includeme(config): + json_renderer = JSON() + json_renderer.add_adapter(tables.Company, company_adapter) + json_renderer.add_adapter(tables.Customer, customer_adapter) + json_renderer.add_adapter(tables.Plan, plan_adapter) + json_renderer.add_adapter(tables.Subscription, subscription_adapter) + json_renderer.add_adapter(tables.Transaction, transaction_adapter) + config.add_renderer('json', json_renderer) diff --git a/billy/request.py b/billy/request.py new file mode 100644 index 0000000..045862d --- /dev/null +++ b/billy/request.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +from pyramid.request import Request +from pyramid.decorator import reify +from pyramid.path import DottedNameResolver + + +class APIRequest(Request): + + @reify + def session(self): + """Session object for database operations + + """ + settings = self.registry.settings + return settings['session'] + + @reify + def processor(self): + """The payment processor + + """ + settings = self.registry.settings + resolver = DottedNameResolver() + processor_factory = settings['billy.processor_factory'] + processor_factory = resolver.maybe_resolve(processor_factory) + processor = processor_factory() + return processor diff --git a/billy/models/payout/__init__.py b/billy/scripts/__init__.py similarity index 100% rename from billy/models/payout/__init__.py rename to billy/scripts/__init__.py diff --git a/billy/scripts/initializedb.py b/billy/scripts/initializedb.py new file mode 100644 index 0000000..d59a94a --- /dev/null +++ b/billy/scripts/initializedb.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals +import os +import sys + +from pyramid.paster import ( + get_appsettings, + setup_logging, +) + +from billy.models import setup_database +from billy.models.tables import DeclarativeBase + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s \n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) != 2: + usage(argv) + config_uri = argv[1] + setup_logging(config_uri) + settings = get_appsettings(config_uri) + settings = setup_database({}, **settings) + engine = settings['engine'] + + DeclarativeBase.metadata.create_all(engine) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py new file mode 100644 index 0000000..1d530c9 --- /dev/null +++ b/billy/scripts/process_transactions.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +import os +import sys +import logging + +import transaction as db_transaction +from pyramid.paster import ( + get_appsettings, + setup_logging, +) +from pyramid.path import DottedNameResolver + +from billy.models import setup_database +from billy.models.subscription import SubscriptionModel +from billy.models.transaction import TransactionModel + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s \n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv, processor=None): + logger = logging.getLogger(__name__) + + if len(argv) != 2: + usage(argv) + config_uri = argv[1] + setup_logging(config_uri) + settings = get_appsettings(config_uri) + settings = setup_database({}, **settings) + + session = settings['session'] + subscription_model = SubscriptionModel(session) + tx_model = TransactionModel(session) + + resolver = DottedNameResolver() + if processor is None: + processor_factory = settings['billy.processor_factory'] + processor_factory = resolver.maybe_resolve(processor_factory) + processor = processor_factory() + + # yield all transactions and commit before we process them, so that + # we won't double process them. + with db_transaction.manager: + logger.info('Yielding transaction ...') + subscription_model.yield_transactions() + + with db_transaction.manager: + logger.info('Processing transaction ...') + tx_model.process_transactions(processor) + logger.info('Done') diff --git a/billy/settings/__init__.py b/billy/settings/__init__.py deleted file mode 100644 index fb1ec28..0000000 --- a/billy/settings/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import unicode_literals -import os - -DEBUG_MODE = os.environ.get('DEBUG_MODE', 'PROD') -DEBUG = True if DEBUG_MODE.lower() == 'dev' else False - - -from .all import * -if DEBUG: - from .debug import * -else: - from .prod import * diff --git a/billy/settings/all.py b/billy/settings/all.py deleted file mode 100644 index 7ca470e..0000000 --- a/billy/settings/all.py +++ /dev/null @@ -1 +0,0 @@ -TEST_API_KEYS = ['wyn6BvYH8AaKqkkq2xL0piuLvZoPymlD', 'XnBBLRa0Ii0wxrXp5ARy'] diff --git a/billy/settings/debug.py b/billy/settings/debug.py deleted file mode 100644 index 3fdd039..0000000 --- a/billy/settings/debug.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -from sqlalchemy import create_engine -from sqlalchemy.engine.url import URL -from sqlalchemy.orm import sessionmaker, scoped_session - -from billy.utils.intervals import Intervals - -DB_SETTINGS = { - 'driver': 'postgresql', - 'host': 'localhost', - 'port': 5432, - 'user': 'test', - 'password': 'test', - 'db_name': 'billy', -} - -DB_URL = URL(DB_SETTINGS['driver'], username=DB_SETTINGS['user'], - host=DB_SETTINGS['host'], - password=DB_SETTINGS['password'], port=DB_SETTINGS['port'], - database=DB_SETTINGS['db_name']) - -DB_ENGINE = create_engine(DB_URL) -Session = scoped_session(sessionmaker(bind=DB_ENGINE)) - -# A list of attempt invervals, [ATTEMPT n DELAY INTERVAL,...] -RETRY_DELAY_PLAN = [ - Intervals.WEEK, - Intervals.TWO_WEEKS, - Intervals.MONTH -] - -RETRY_DELAY_PAYOUT = [ - Intervals.DAY, - Intervals.DAY * 3, - Intervals.WEEK -] diff --git a/billy/tests/__init__.py b/billy/tests/__init__.py index bc3b4bb..e69de29 100644 --- a/billy/tests/__init__.py +++ b/billy/tests/__init__.py @@ -1,25 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import unittest - - -from billy.models import ProcessorType, Company - - -class BaseTestCase(unittest.TestCase): - def setUp(self): - super(BaseTestCase, self).setUp() - self.test_company_keys = ['BILLY_TEST_KEY_1', - 'BILLY_TEST_KEY_2', - 'BILLY_TEST_KEY_3'] - self.test_companies = [] - for credential in self.test_company_keys: - Company.query.filter(Company.processor_credential == credential).delete() - self.test_companies.append( - Company.create(ProcessorType.DUMMY, credential, is_test=True)) - - -def rel_delta_to_sec(rel): - now = datetime.datetime.now() - return ((now + rel) - now).total_seconds() diff --git a/billy/tests/fixtures/__init__.py b/billy/tests/fixtures/__init__.py deleted file mode 100644 index 020e426..0000000 --- a/billy/tests/fixtures/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import unicode_literals - -from .company import sample_company -from .coupon import sample_coupon -from .customer import sample_customer -from .payout import sample_payout -from .plan import sample_plan - diff --git a/billy/tests/fixtures/company.py b/billy/tests/fixtures/company.py deleted file mode 100644 index 176f8f4..0000000 --- a/billy/tests/fixtures/company.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import unicode_literals - -from billy.models.processor import ProcessorType - - -def sample_company( - processor_type=ProcessorType.DUMMY, - processor_credential='MY_DUMMY_API_KEY_1', - is_test=True): - return dict( - processor_type=processor_type, - processor_credential=processor_credential, - is_test=is_test - ) \ No newline at end of file diff --git a/billy/tests/fixtures/coupon.py b/billy/tests/fixtures/coupon.py deleted file mode 100644 index f3313f5..0000000 --- a/billy/tests/fixtures/coupon.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import unicode_literals - - -def sample_coupon( - your_id='10_OFF_COUPON', - name='First Invoice 10 off', - price_off_cents=0, - percent_off_int=10, - max_redeem=-1, - repeating=10): - - return dict( - your_id=your_id, - name=name, - price_off_cents=price_off_cents, - percent_off_int=percent_off_int, - max_redeem=max_redeem, - repeating=repeating - ) \ No newline at end of file diff --git a/billy/tests/fixtures/customer.py b/billy/tests/fixtures/customer.py deleted file mode 100644 index c87ff2c..0000000 --- a/billy/tests/fixtures/customer.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import unicode_literals - - -def sample_customer( - your_id='customer_1215', - processor_id='CUDEXKX1285DKE38DDK'): - return dict( - your_id=your_id, - processor_id=processor_id - ) diff --git a/billy/tests/fixtures/payout.py b/billy/tests/fixtures/payout.py deleted file mode 100644 index 14406f6..0000000 --- a/billy/tests/fixtures/payout.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils.intervals import Intervals - - -def sample_payout(your_id='5_DOLLA_PLAN', - name='The 5 dollar Payout', - balance_to_keep_cents=500, - payout_interval=Intervals.WEEK): - return dict( - your_id=your_id, - name=name, - balance_to_keep_cents=balance_to_keep_cents, - payout_interval=payout_interval - ) diff --git a/billy/tests/fixtures/plan.py b/billy/tests/fixtures/plan.py deleted file mode 100644 index 7a06aa9..0000000 --- a/billy/tests/fixtures/plan.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import unicode_literals - -from billy.utils.intervals import Intervals - - -def sample_plan( - your_id='PRO_PLAN', - name='The Pro Plan', - price_cents=1000, - plan_interval=Intervals.MONTH, - trial_interval=Intervals.WEEK): - return dict( - your_id=your_id, - name=name, - price_cents=price_cents, - plan_interval=plan_interval, - trial_interval=trial_interval - ) diff --git a/billy/tests/fixtures/schemas/customer.json b/billy/tests/fixtures/schemas/customer.json deleted file mode 100644 index fee0490..0000000 --- a/billy/tests/fixtures/schemas/customer.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "properties": { - "id": { - "type": "string", - "required": true - }, - "created_at": { - "type": "string", - "format": "date-time", - "required": true - }, - "provider_id": { - "type": "string", - "required": true - }, - "last_debt_clear": { - "type": ["string", "null"], - "format": "date-time", - "required": true - }, - "charge_attempts": { - "type": "integer", - "required": true - }, - "current_coupon": { - "type": ["string", "null"], - "required": true - } - }, - "additionalProperties": false -} \ No newline at end of file diff --git a/billy/tests/test_models/__init__.py b/billy/tests/functional/__init__.py similarity index 100% rename from billy/tests/test_models/__init__.py rename to billy/tests/functional/__init__.py diff --git a/billy/tests/functional/helper.py b/billy/tests/functional/helper.py new file mode 100644 index 0000000..8bcdbe9 --- /dev/null +++ b/billy/tests/functional/helper.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals +import os +import unittest + + +class ViewTestCase(unittest.TestCase): + + def setUp(self): + from webtest import TestApp + from billy import main + from billy.models import setup_database + from billy.models.tables import DeclarativeBase + + if hasattr(self, 'settings'): + settings = self.settings + else: + settings = {} + + # init database + db_url = os.environ.get('BILLY_FUNC_TEST_DB', 'sqlite://') + settings['sqlalchemy.url'] = db_url + if hasattr(ViewTestCase, '_engine'): + settings['engine'] = ViewTestCase._engine + settings = setup_database({}, **settings) + # we want to save and reuse the SQL connection if it is not SQLite, + # otherwise, somehow, it will create too many connections to server + # and make the testing results in failure + if settings['engine'].name != 'sqlite': + ViewTestCase._engine = settings['engine'] + DeclarativeBase.metadata.bind = settings['engine'] + DeclarativeBase.metadata.create_all() + + app = main({}, **settings) + self.testapp = TestApp(app) + self.testapp.session = settings['session'] + + def tearDown(self): + from billy.models.tables import DeclarativeBase + self.testapp.session.remove() + DeclarativeBase.metadata.drop_all() diff --git a/billy/tests/functional/test_auth.py b/billy/tests/functional/test_auth.py new file mode 100644 index 0000000..dc71bd3 --- /dev/null +++ b/billy/tests/functional/test_auth.py @@ -0,0 +1,76 @@ +from __future__ import unicode_literals +import base64 + +from webtest.app import TestRequest + +from billy.tests.functional.helper import ViewTestCase + + +class TestAuth(ViewTestCase): + + def make_one(self): + from billy.api.auth import get_remote_user + return get_remote_user + + def test_get_remote(self): + get_remote_user = self.make_one() + + encoded = base64.b64encode('USERNAME:PASSWORD') + auth = 'basic {}'.format(encoded) + + request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) + user = get_remote_user(request) + self.assertEqual(user, 'USERNAME') + + def test_get_remote_without_base64_part(self): + get_remote_user = self.make_one() + + encoded = base64.b64encode('USERNAME') + auth = 'basic {}'.format(encoded) + + request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) + user = get_remote_user(request) + self.assertEqual(user, None) + + def test_get_remote_bad_base64(self): + get_remote_user = self.make_one() + request = TestRequest(dict(HTTP_AUTHORIZATION='basic Breaking####Bad')) + user = get_remote_user(request) + self.assertEqual(user, None) + + def test_get_remote_without_colon(self): + get_remote_user = self.make_one() + request = TestRequest(dict(HTTP_AUTHORIZATION='basic')) + user = get_remote_user(request) + self.assertEqual(user, None) + + def test_get_remote_non_basic(self): + get_remote_user = self.make_one() + request = TestRequest(dict(HTTP_AUTHORIZATION='foobar XXX')) + user = get_remote_user(request) + self.assertEqual(user, None) + + def test_get_remote_user_with_empty_environ(self): + get_remote_user = self.make_one() + request = TestRequest({}) + user = get_remote_user(request) + self.assertEqual(user, None) + + def test_basic_auth_tween(self): + from billy.api.auth import basic_auth_tween_factory + + encoded = base64.b64encode('USERNAME:PASSWORD') + auth = 'basic {}'.format(encoded) + request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) + + called = [] + + def handler(request): + called.append(True) + return 'RESPONSE' + + basic_auth_tween = basic_auth_tween_factory(handler, None) + response = basic_auth_tween(request) + + self.assertEqual(response, 'RESPONSE') + self.assertEqual(called, [True]) diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py new file mode 100644 index 0000000..ac1a864 --- /dev/null +++ b/billy/tests/functional/test_company.py @@ -0,0 +1,108 @@ +from __future__ import unicode_literals +import datetime + +from freezegun import freeze_time + +from billy.tests.functional.helper import ViewTestCase + + +@freeze_time('2013-08-16') +class TestCompanyViews(ViewTestCase): + + def test_create_company(self): + processor_key = 'MOCK_PROCESSOR_KEY' + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + self.failUnless('processor_key' not in res.json) + self.failUnless('guid' in res.json) + self.failUnless('api_key' in res.json) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) + + def test_create_company_with_bad_parameters(self): + self.testapp.post( + '/v1/companies/', + status=400, + ) + + def test_get_company(self): + processor_key = 'MOCK_PROCESSOR_KEY' + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + created_company = res.json + guid = created_company['guid'] + api_key = str(created_company['api_key']) + res = self.testapp.get( + '/v1/companies/{}'.format(guid), + extra_environ=dict(REMOTE_USER=api_key), + status=200, + ) + self.assertEqual(res.json, created_company) + + def test_get_company_with_bad_api_key(self): + processor_key = 'MOCK_PROCESSOR_KEY' + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + created_company = res.json + guid = created_company['guid'] + res = self.testapp.get( + '/v1/companies/{}'.format(guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_non_existing_company(self): + processor_key = 'MOCK_PROCESSOR_KEY' + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + api_key = str(res.json['api_key']) + self.testapp.get( + '/v1/companies/NON_EXIST', + extra_environ=dict(REMOTE_USER=api_key), + status=404 + ) + + def test_get_other_company(self): + processor_key = 'MOCK_PROCESSOR_KEY' + + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + api_key1 = str(res.json['api_key']) + guid1 = res.json['guid'] + + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=processor_key), + status=200 + ) + api_key2 = str(res.json['api_key']) + guid2 = res.json['guid'] + + self.testapp.get( + '/v1/companies/{}'.format(guid2), + extra_environ=dict(REMOTE_USER=api_key1), + status=403, + ) + self.testapp.get( + '/v1/companies/{}'.format(guid1), + extra_environ=dict(REMOTE_USER=api_key2), + status=403, + ) \ No newline at end of file diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py new file mode 100644 index 0000000..40b7843 --- /dev/null +++ b/billy/tests/functional/test_customer.py @@ -0,0 +1,100 @@ +from __future__ import unicode_literals +import datetime + +import transaction as db_transaction +from freezegun import freeze_time + +from billy.tests.functional.helper import ViewTestCase + + +@freeze_time('2013-08-16') +class TestCustomerViews(ViewTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + super(TestCustomerViews, self).setUp() + model = CompanyModel(self.testapp.session) + with db_transaction.manager: + self.company_guid = model.create(processor_key='MOCK_PROCESSOR_KEY') + company = model.get(self.company_guid) + self.api_key = str(company.api_key) + + def test_create_customer(self): + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + + res = self.testapp.post( + '/v1/customers/', + dict(external_id='MOCK_EXTERNAL_ID'), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.failUnless('guid' in res.json) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) + self.assertEqual(res.json['external_id'], 'MOCK_EXTERNAL_ID') + self.assertEqual(res.json['company_guid'], self.company_guid) + + def test_create_customer_with_bad_api_key(self): + self.testapp.post( + '/v1/customers/', + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_customer(self): + res = self.testapp.post( + '/v1/customers/', + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + created_customer = res.json + + guid = created_customer['guid'] + res = self.testapp.get( + '/v1/customers/{}'.format(guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json, created_customer) + + def test_get_customer_with_bad_api_key(self): + res = self.testapp.post( + '/v1/customers/', + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + created_customer = res.json + + guid = created_customer['guid'] + res = self.testapp.get( + '/v1/customers/{}'.format(guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_non_existing_customer(self): + self.testapp.get( + '/v1/customers/NON_EXIST', + extra_environ=dict(REMOTE_USER=self.api_key), + status=404 + ) + + def test_get_customer_of_other_company(self): + from billy.models.company import CompanyModel + model = CompanyModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = model.create(processor_key='MOCK_PROCESSOR_KEY') + other_company = model.get(other_company_guid) + other_api_key = str(other_company.api_key) + res = self.testapp.post( + '/v1/customers/', + extra_environ=dict(REMOTE_USER=other_api_key), + status=200, + ) + guid = res.json['guid'] + res = self.testapp.get( + '/v1/customers/{}'.format(guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) diff --git a/billy/tests/functional/test_initializedb.py b/billy/tests/functional/test_initializedb.py new file mode 100644 index 0000000..04e5c25 --- /dev/null +++ b/billy/tests/functional/test_initializedb.py @@ -0,0 +1,64 @@ +from __future__ import unicode_literals +import os +import sys +import unittest +import tempfile +import shutil +import textwrap +import sqlite3 +import StringIO + + +class TestInitializedb(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_usage(self): + from billy.scripts import initializedb + + filename = '/path/to/initializedb' + + old_stdout = sys.stdout + usage_out = StringIO.StringIO() + sys.stdout = usage_out + try: + with self.assertRaises(SystemExit): + initializedb.main([filename]) + finally: + sys.stdout = old_stdout + expected = textwrap.dedent("""\ + usage: initializedb + (example: "initializedb development.ini") + """) + self.assertMultiLineEqual(usage_out.getvalue(), expected) + + def test_main(self): + from billy.scripts import initializedb + cfg_path = os.path.join(self.temp_dir, 'config.ini') + with open(cfg_path, 'wt') as f: + f.write(textwrap.dedent("""\ + [app:main] + use = egg:billy + + sqlalchemy.url = sqlite:///%(here)s/billy.sqlite + """)) + initializedb.main([initializedb.__file__, cfg_path]) + + sqlite_path = os.path.join(self.temp_dir, 'billy.sqlite') + self.assertTrue(os.path.exists(sqlite_path)) + + conn = sqlite3.connect(sqlite_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = [row[0] for row in cursor.fetchall()] + self.assertEqual(set(tables), set([ + 'company', + 'customer', + 'plan', + 'subscription', + 'transaction', + ])) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py new file mode 100644 index 0000000..58774bb --- /dev/null +++ b/billy/tests/functional/test_plan.py @@ -0,0 +1,255 @@ +from __future__ import unicode_literals +import datetime + +import transaction as db_transaction +from freezegun import freeze_time + +from billy.tests.functional.helper import ViewTestCase + + +@freeze_time('2013-08-16') +class TestPlanViews(ViewTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + super(TestPlanViews, self).setUp() + model = CompanyModel(self.testapp.session) + with db_transaction.manager: + self.company_guid = model.create(processor_key='MOCK_PROCESSOR_KEY') + company = model.get(self.company_guid) + self.api_key = str(company.api_key) + + def test_create_plan(self): + plan_type = 'charge' + amount = '55.66' + frequency = 'weekly' + interval = 123 + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type=plan_type, + amount=amount, + frequency=frequency, + interval=interval, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.failUnless('guid' in res.json) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) + self.assertEqual(res.json['plan_type'], plan_type) + self.assertEqual(res.json['amount'], amount) + self.assertEqual(res.json['frequency'], frequency) + self.assertEqual(res.json['interval'], interval) + self.assertEqual(res.json['company_guid'], self.company_guid) + + def test_create_plan_with_bad_parameters(self): + def assert_bad_parameters(params): + self.testapp.post( + '/v1/plans/', + params, + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + assert_bad_parameters(dict()) + assert_bad_parameters(dict( + frequency='weekly', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='charge', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + )) + assert_bad_parameters(dict( + plan_type='', + frequency='weekly', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='super_charge', + frequency='weekly', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='decade', + amount='55.66', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + amount='', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + amount='-123', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + amount='55.66', + interval='0', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + amount='55.66', + interval='0.5', + )) + assert_bad_parameters(dict( + plan_type='charge', + frequency='weekly', + amount='55.66', + interval='-123', + )) + + def test_create_plan_with_empty_interval(self): + # TODO: this case is a little bit strange, empty interval string + # value should result in the default interval 1, however, WTForms + # will yield None in this case, so we need to deal it specifically. + # not sure is it a bug of WTForm, maybe we should workaround this later + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency='weekly', + interval='', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json['interval'], 1) + + def test_create_plan_with_different_types(self): + def assert_plan_type(plan_type): + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type=plan_type, + amount='55.66', + frequency='weekly', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json['plan_type'], plan_type) + + assert_plan_type('charge') + assert_plan_type('payout') + + def test_create_plan_with_different_frequency(self): + def assert_frequency(frequency): + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency=frequency, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json['frequency'], frequency) + + assert_frequency('daily') + assert_frequency('weekly') + assert_frequency('monthly') + assert_frequency('yearly') + + def test_create_plan_with_bad_api_key(self): + self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency='weekly', + ), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_plan(self): + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency='weekly', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + created_plan = res.json + + guid = created_plan['guid'] + res = self.testapp.get( + '/v1/plans/{}'.format(guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json, created_plan) + + def test_get_non_existing_plan(self): + self.testapp.get( + '/v1/plans/NON_EXIST', + extra_environ=dict(REMOTE_USER=self.api_key), + status=404 + ) + + def test_get_plan_with_bad_api_key(self): + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency='weekly', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + + guid = res.json['guid'] + res = self.testapp.get( + '/v1/plans/{}'.format(guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_plan_of_other_company(self): + from billy.models.company import CompanyModel + model = CompanyModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = model.create(processor_key='MOCK_PROCESSOR_KEY') + other_company = model.get(other_company_guid) + other_api_key = str(other_company.api_key) + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='55.66', + frequency='weekly', + ), + extra_environ=dict(REMOTE_USER=other_api_key), + status=200, + ) + guid = res.json['guid'] + res = self.testapp.get( + '/v1/plans/{}'.format(guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py new file mode 100644 index 0000000..5977bbe --- /dev/null +++ b/billy/tests/functional/test_process_transactions.py @@ -0,0 +1,172 @@ +from __future__ import unicode_literals +import os +import sys +import unittest +import tempfile +import shutil +import textwrap +import StringIO + +import transaction as db_transaction +from flexmock import flexmock + + +class TestProcessTransactions(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_usage(self): + from billy.scripts.process_transactions import main + + filename = '/path/to/process_transactions' + + old_stdout = sys.stdout + usage_out = StringIO.StringIO() + sys.stdout = usage_out + try: + with self.assertRaises(SystemExit): + main([filename]) + finally: + sys.stdout = old_stdout + expected = textwrap.dedent("""\ + usage: process_transactions + (example: "process_transactions development.ini") + """) + self.assertMultiLineEqual(usage_out.getvalue(), expected) + + def test_main(self): + from billy.models.transaction import TransactionModel + from billy.models.processors.balanced_payments import BalancedProcessor + from billy.scripts import initializedb + from billy.scripts import process_transactions + + def mock_process_transactions(processor): + self.assertIsInstance(processor, BalancedProcessor) + + ( + flexmock(TransactionModel) + .should_receive('process_transactions') + .replace_with(mock_process_transactions) + .once() + ) + + cfg_path = os.path.join(self.temp_dir, 'config.ini') + with open(cfg_path, 'wt') as f: + f.write(textwrap.dedent("""\ + [app:main] + use = egg:billy + + sqlalchemy.url = sqlite:///%(here)s/billy.sqlite + billy.processor_factory = billy.models.processors.balanced_payments.BalancedProcessor + """)) + initializedb.main([initializedb.__file__, cfg_path]) + process_transactions.main([process_transactions.__file__, cfg_path]) + # TODO: do more check here? + + def test_main_with_crash(self): + from pyramid.paster import get_appsettings + from billy.models import setup_database + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + from billy.models.subscription import SubscriptionModel + from billy.scripts import initializedb + from billy.scripts import process_transactions + + class MockProcessor(object): + + def __init__(self): + self.charges = {} + self.tx_sn = 0 + self.called_times = 0 + + def create_customer(self, customer): + return 'MOCK_PROCESSOR_CUSTOMER_ID' + + def prepare_customer(self, customer, payment_uri=None): + pass + + def charge(self, transaction): + self.called_times += 1 + if self.called_times == 2: + raise KeyboardInterrupt + guid = transaction.guid + if guid in self.charges: + return self.charges[guid] + self.charges[guid] = self.tx_sn + self.tx_sn += 1 + + mock_processor = MockProcessor() + + cfg_path = os.path.join(self.temp_dir, 'config.ini') + with open(cfg_path, 'wt') as f: + f.write(textwrap.dedent("""\ + [app:main] + use = egg:billy + + sqlalchemy.url = sqlite:///%(here)s/billy.sqlite + """)) + initializedb.main([initializedb.__file__, cfg_path]) + + settings = get_appsettings(cfg_path) + settings = setup_database({}, **settings) + session = settings['session'] + company_model = CompanyModel(session) + customer_model = CustomerModel(session) + plan_model = PlanModel(session) + subscription_model = SubscriptionModel(session) + + with db_transaction.manager: + company_guid = company_model.create('my_secret_key') + plan_guid = plan_model.create( + company_guid=company_guid, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + frequency=plan_model.FREQ_MONTHLY, + ) + customer_guid = customer_model.create( + company_guid=company_guid, + ) + subscription_model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + payment_uri='/v1/cards/tester', + ) + subscription_model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + payment_uri='/v1/cards/tester', + ) + + with self.assertRaises(KeyboardInterrupt): + process_transactions.main([process_transactions.__file__, cfg_path], + processor=mock_processor) + + process_transactions.main([process_transactions.__file__, cfg_path], + processor=mock_processor) + + # here is the story, we have two subscriptions here + # + # Subscription1 + # Subscription2 + # + # And the time is not advanced, so we should only have two transactions + # to be yielded and processed. However, we assume bad thing happens + # durring the process. We let the second call to charge of processor + # raises a KeyboardInterrupt error. So, it would look like this + # + # charge for transaction from Subscription1 + # charge for transaction from Subscription2 (Crash) + # + # Then, we perform the process_transactions again, if it works + # correctly, the first transaction is already yield and processed. + # + # charge for transaction from Subscription2 + # + # So, there would only be two charges in processor. This is mainly + # for making sure we won't duplicate charges/payouts + self.assertEqual(len(mock_processor.charges), 2) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py new file mode 100644 index 0000000..9edff7a --- /dev/null +++ b/billy/tests/functional/test_subscription.py @@ -0,0 +1,611 @@ +from __future__ import unicode_literals +import datetime +import decimal + +import transaction as db_transaction +from flexmock import flexmock +from freezegun import freeze_time + +from billy.tests.functional.helper import ViewTestCase + + +class DummyProcessor(object): + + def create_customer(self, customer): + pass + + def prepare_customer(self, customer, payment_uri=None): + pass + + def charge(self, transaction): + pass + + def payout(self, transaction): + pass + + def refund(self, transaction): + pass + + +@freeze_time('2013-08-16') +class TestSubscriptionViews(ViewTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + self.settings = { + 'billy.processor_factory': DummyProcessor + } + super(TestSubscriptionViews, self).setUp() + company_model = CompanyModel(self.testapp.session) + customer_model = CustomerModel(self.testapp.session) + plan_model = PlanModel(self.testapp.session) + with db_transaction.manager: + self.company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + self.customer_guid = customer_model.create( + company_guid=self.company_guid + ) + self.plan_guid = plan_model.create( + company_guid=self.company_guid, + frequency=plan_model.FREQ_WEEKLY, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + ) + company = company_model.get(self.company_guid) + self.api_key = str(company.api_key) + + def test_create_subscription(self): + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + + customer_guid = self.customer_guid + plan_guid = self.plan_guid + amount = '55.66' + payment_uri = 'MOCK_CARD_URI' + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + # next week + next_transaction_at = datetime.datetime(2013, 8, 23) + next_iso = next_transaction_at.isoformat() + + def mock_charge(transaction): + self.assertEqual(transaction.subscription.customer_guid, + customer_guid) + self.assertEqual(transaction.subscription.plan_guid, + plan_guid) + return 'MOCK_PROCESSOR_TRANSACTION_ID' + + mock_processor = flexmock(DummyProcessor) + ( + mock_processor + .should_receive('create_customer') + .once() + ) + + ( + mock_processor + .should_receive('charge') + .replace_with(mock_charge) + .once() + ) + + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + payment_uri=payment_uri, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.failUnless('guid' in res.json) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) + self.assertEqual(res.json['canceled_at'], None) + self.assertEqual(res.json['next_transaction_at'], next_iso) + self.assertEqual(res.json['period'], 1) + self.assertEqual(res.json['amount'], amount) + self.assertEqual(res.json['customer_guid'], customer_guid) + self.assertEqual(res.json['plan_guid'], plan_guid) + self.assertEqual(res.json['payment_uri'], payment_uri) + + subscription_model = SubscriptionModel(self.testapp.session) + subscription = subscription_model.get(res.json['guid']) + self.assertEqual(len(subscription.transactions), 1) + transaction = subscription.transactions[0] + self.assertEqual(transaction.external_id, + 'MOCK_PROCESSOR_TRANSACTION_ID') + self.assertEqual(transaction.status, TransactionModel.STATUS_DONE) + + def test_create_subscription_with_past_started_at(self): + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + started_at='2013-08-15T23:59:59Z', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + + def test_create_subscription_with_bad_parameters(self): + def assert_bad_parameters(params): + self.testapp.post( + '/v1/subscriptions/', + params, + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + assert_bad_parameters({}) + assert_bad_parameters(dict(customer_guid=self.customer_guid)) + assert_bad_parameters(dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='BAD_AMOUNT', + )) + assert_bad_parameters(dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='-123.45', + )) + assert_bad_parameters(dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='0', + )) + assert_bad_parameters(dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + started_at='BAD_DATETIME', + )) + assert_bad_parameters(dict( + customer_guid=self.plan_guid, + plan_guid=self.plan_guid, + )) + assert_bad_parameters(dict( + customer_guid=self.customer_guid, + plan_guid=self.customer_guid, + )) + + def test_create_subscription_with_started_at(self): + customer_guid = self.customer_guid + plan_guid = self.plan_guid + amount = '55.66' + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + # next week + next_transaction_at = datetime.datetime(2013, 8, 17) + next_iso = next_transaction_at.isoformat() + + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + started_at='2013-08-17T00:00:00Z', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.failUnless('guid' in res.json) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) + self.assertEqual(res.json['next_transaction_at'], next_iso) + self.assertEqual(res.json['period'], 0) + self.assertEqual(res.json['amount'], amount) + self.assertEqual(res.json['customer_guid'], customer_guid) + self.assertEqual(res.json['plan_guid'], plan_guid) + + def test_create_subscription_with_started_at_and_timezone(self): + customer_guid = self.customer_guid + plan_guid = self.plan_guid + amount = '55.66' + # next week + next_transaction_at = datetime.datetime(2013, 8, 17) + next_iso = next_transaction_at.isoformat() + + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + started_at='2013-08-17T08:00:00+08:00', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.failUnless('guid' in res.json) + self.assertEqual(res.json['next_transaction_at'], next_iso) + self.assertEqual(res.json['period'], 0) + + def test_create_subscription_with_bad_api(self): + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_subscription(self): + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + created_subscriptions = res.json + + guid = created_subscriptions['guid'] + res = self.testapp.get( + '/v1/subscriptions/{}'.format(guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json, created_subscriptions) + + def test_get_non_existing_subscription(self): + self.testapp.get( + '/v1/subscriptions/NON_EXIST', + extra_environ=dict(REMOTE_USER=self.api_key), + status=404 + ) + + def test_get_subscription_with_bad_api_key(self): + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + + guid = res.json['guid'] + res = self.testapp.get( + '/v1/subscriptions/{}'.format(guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_subscription_of_other_company(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + + company_model = CompanyModel(self.testapp.session) + customer_model = CustomerModel(self.testapp.session) + plan_model = PlanModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + other_customer_guid = customer_model.create( + company_guid=other_company_guid + ) + other_plan_guid = plan_model.create( + company_guid=other_company_guid, + frequency=plan_model.FREQ_WEEKLY, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + ) + other_company = company_model.get(other_company_guid) + other_api_key = str(other_company.api_key) + + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=other_customer_guid, + plan_guid=other_plan_guid, + ), + extra_environ=dict(REMOTE_USER=other_api_key), + status=200, + ) + other_guid = res.json['guid'] + + self.testapp.get( + '/v1/subscriptions/{}'.format(other_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) + + def test_create_subscription_to_other_company_customer(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + + company_model = CompanyModel(self.testapp.session) + customer_model = CustomerModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + other_customer_guid = customer_model.create( + company_guid=other_company_guid + ) + + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=other_customer_guid, + plan_guid=self.plan_guid, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) + + def test_create_subscription_to_other_company_plan(self): + from billy.models.company import CompanyModel + from billy.models.plan import PlanModel + + company_model = CompanyModel(self.testapp.session) + plan_model = PlanModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + other_plan_guid = plan_model.create( + company_guid=other_company_guid, + frequency=plan_model.FREQ_WEEKLY, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + ) + + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=other_plan_guid, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) + + def test_cancel_subscription(self): + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + + subscription_model = SubscriptionModel(self.testapp.session) + tx_model = TransactionModel(self.testapp.session) + now = datetime.datetime.utcnow() + + with db_transaction.manager: + subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + tx_model.create( + subscription_guid=subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=100, + scheduled_at=now, + ) + + with freeze_time('2013-08-16 07:00:00'): + canceled_at = datetime.datetime.utcnow() + res = self.testapp.post( + '/v1/subscriptions/{}/cancel'.format(subscription_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + + subscription = res.json + self.assertEqual(subscription['canceled'], True) + self.assertEqual(subscription['canceled_at'], canceled_at.isoformat()) + + def test_cancel_subscription_to_other_company(self): + from billy.models.subscription import SubscriptionModel + from billy.models.company import CompanyModel + + subscription_model = SubscriptionModel(self.testapp.session) + company_model = CompanyModel(self.testapp.session) + + with db_transaction.manager: + subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + other_company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + other_company = company_model.get(other_company_guid) + other_api_key = str(other_company.api_key) + + self.testapp.post( + '/v1/subscriptions/{}/cancel'.format(subscription_guid), + extra_environ=dict(REMOTE_USER=other_api_key), + status=403, + ) + + def test_cancel_subscription_with_prorated_refund(self): + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + + subscription_model = SubscriptionModel(self.testapp.session) + tx_model = TransactionModel(self.testapp.session) + now = datetime.datetime.utcnow() + + with db_transaction.manager: + subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount=100, + ) + tx_guid = tx_model.create( + subscription_guid=subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=100, + scheduled_at=now, + ) + subscription = subscription_model.get(subscription_guid) + subscription.period = 1 + subscription.next_transaction_at = datetime.datetime(2013, 8, 23) + self.testapp.session.add(subscription) + + transaction = tx_model.get(tx_guid) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.testapp.session.add(transaction) + + refund_called = [] + + def mock_refund(transaction): + refund_called.append(transaction) + return 'MOCK_PROCESSOR_REFUND_URI' + + mock_processor = flexmock(DummyProcessor) + ( + mock_processor + .should_receive('refund') + .replace_with(mock_refund) + .once() + ) + + with freeze_time('2013-08-17'): + canceled_at = datetime.datetime.utcnow() + res = self.testapp.post( + '/v1/subscriptions/{}/cancel'.format(subscription_guid), + dict(prorated_refund=True), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + + subscription = res.json + self.assertEqual(subscription['canceled'], True) + self.assertEqual(subscription['canceled_at'], canceled_at.isoformat()) + + transaction = refund_called[0] + self.testapp.session.add(transaction) + self.assertEqual(transaction.refund_to.guid, tx_guid) + self.assertEqual(transaction.subscription_guid, subscription_guid) + # only one day is elapsed, and it is a weekly plan, so + # it should be 100 - (100 / 7) and round to cent, 85.71 + self.assertEqual(transaction.amount, decimal.Decimal('85.71')) + self.assertEqual(transaction.status, tx_model.STATUS_DONE) + + res = self.testapp.get( + '/v1/transactions/', + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + guids = [item['guid'] for item in res.json['items']] + self.assertEqual(set(guids), set([tx_guid, transaction.guid])) + + def test_cancel_subscription_with_refund_amount(self): + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + + subscription_model = SubscriptionModel(self.testapp.session) + tx_model = TransactionModel(self.testapp.session) + now = datetime.datetime.utcnow() + + with db_transaction.manager: + subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + tx_guid = tx_model.create( + subscription_guid=subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=10, + scheduled_at=now, + ) + subscription = subscription_model.get(subscription_guid) + subscription.period = 1 + subscription.next_transaction_at = datetime.datetime(2013, 8, 23) + self.testapp.session.add(subscription) + + transaction = tx_model.get(tx_guid) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.testapp.session.add(transaction) + + refund_called = [] + + def mock_refund(transaction): + refund_called.append(transaction) + return 'MOCK_PROCESSOR_REFUND_URI' + + mock_processor = flexmock(DummyProcessor) + ( + mock_processor + .should_receive('refund') + .replace_with(mock_refund) + .once() + ) + + res = self.testapp.post( + '/v1/subscriptions/{}/cancel'.format(subscription_guid), + dict(refund_amount='2.34'), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + subscription = res.json + + transaction = refund_called[0] + self.testapp.session.add(transaction) + self.assertEqual(transaction.refund_to.guid, tx_guid) + self.assertEqual(transaction.subscription_guid, subscription_guid) + self.assertEqual(transaction.amount, decimal.Decimal('2.34')) + self.assertEqual(transaction.status, tx_model.STATUS_DONE) + + res = self.testapp.get( + '/v1/transactions/', + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + guids = [item['guid'] for item in res.json['items']] + self.assertEqual(set(guids), set([tx_guid, transaction.guid])) + + def test_cancel_subscription_with_bad_arguments(self): + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + + subscription_model = SubscriptionModel(self.testapp.session) + tx_model = TransactionModel(self.testapp.session) + now = datetime.datetime.utcnow() + + with db_transaction.manager: + subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount=100, + ) + tx_guid = tx_model.create( + subscription_guid=subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=100, + scheduled_at=now, + ) + subscription = subscription_model.get(subscription_guid) + subscription.period = 1 + subscription.next_transaction_at = datetime.datetime(2013, 8, 23) + self.testapp.session.add(subscription) + + transaction = tx_model.get(tx_guid) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.testapp.session.add(transaction) + + def assert_bad_parameters(kwargs): + self.testapp.post( + '/v1/subscriptions/{}/cancel'.format(subscription_guid), + kwargs, + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + assert_bad_parameters(dict(prorated_refund=True, refund_amount=10)) + assert_bad_parameters(dict(refund_amount='100.01')) diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py new file mode 100644 index 0000000..40fca8a --- /dev/null +++ b/billy/tests/functional/test_transaction.py @@ -0,0 +1,209 @@ +from __future__ import unicode_literals +import datetime + +import transaction as db_transaction +from freezegun import freeze_time + +from billy.tests.functional.helper import ViewTestCase + + +@freeze_time('2013-08-16') +class TestTransactionViews(ViewTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + super(TestTransactionViews, self).setUp() + company_model = CompanyModel(self.testapp.session) + customer_model = CustomerModel(self.testapp.session) + plan_model = PlanModel(self.testapp.session) + subscription_model = SubscriptionModel(self.testapp.session) + transaction_model = TransactionModel(self.testapp.session) + with db_transaction.manager: + self.company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + self.customer_guid = customer_model.create( + company_guid=self.company_guid + ) + self.plan_guid = plan_model.create( + company_guid=self.company_guid, + frequency=plan_model.FREQ_WEEKLY, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + ) + self.subscription_guid = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + self.transaction_guid = transaction_model.create( + subscription_guid=self.subscription_guid, + transaction_type=transaction_model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + company = company_model.get(self.company_guid) + self.api_key = str(company.api_key) + + def test_get_transaction(self): + from billy.models.transaction import TransactionModel + transaction_model = TransactionModel(self.testapp.session) + res = self.testapp.get( + '/v1/transactions/{}'.format(self.transaction_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + transaction = transaction_model.get(self.transaction_guid) + self.assertEqual(res.json['guid'], transaction.guid) + self.assertEqual(res.json['created_at'], + transaction.created_at.isoformat()) + self.assertEqual(res.json['updated_at'], + transaction.updated_at.isoformat()) + self.assertEqual(res.json['scheduled_at'], + transaction.scheduled_at.isoformat()) + self.assertEqual(res.json['amount'], str(transaction.amount)) + self.assertEqual(res.json['payment_uri'], transaction.payment_uri) + self.assertEqual(res.json['transaction_type'], 'charge') + self.assertEqual(res.json['status'], 'init') + self.assertEqual(res.json['error_message'], None) + self.assertEqual(res.json['failure_count'], 0) + self.assertEqual(res.json['external_id'], None) + self.assertEqual(res.json['subscription_guid'], + transaction.subscription_guid) + + def test_transaction_list_by_company(self): + from billy.models.transaction import TransactionModel + transaction_model = TransactionModel(self.testapp.session) + guids = [self.transaction_guid] + with db_transaction.manager: + for i in range(9): + with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)): + guid = transaction_model.create( + subscription_guid=self.subscription_guid, + transaction_type=transaction_model.TYPE_CHARGE, + amount=10 * i, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + guids.append(guid) + res = self.testapp.get( + '/v1/transactions/?offset=5&limit=3', + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + self.assertEqual(res.json['offset'], 5) + self.assertEqual(res.json['limit'], 3) + items = res.json['items'] + result_guids = [item['guid'] for item in items] + self.assertEqual(set(result_guids), set(guids[5:8])) + + def test_transaction_list_by_company_with_bad_api_key(self): + self.testapp.get( + '/v1/transactions/', + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_transaction_with_different_types(self): + from billy.models.transaction import TransactionModel + transaction_model = TransactionModel(self.testapp.session) + + def assert_type(tx_type, expected): + with db_transaction.manager: + transaction = transaction_model.get(self.transaction_guid) + transaction.transaction_type = tx_type + self.testapp.session.add(transaction) + + res = self.testapp.get( + '/v1/transactions/{}'.format(self.transaction_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + transaction = transaction_model.get(self.transaction_guid) + self.assertEqual(res.json['transaction_type'], expected) + + assert_type(transaction_model.TYPE_CHARGE, 'charge') + assert_type(transaction_model.TYPE_PAYOUT, 'payout') + assert_type(transaction_model.TYPE_REFUND, 'refund') + + def test_get_transaction_with_different_status(self): + from billy.models.transaction import TransactionModel + transaction_model = TransactionModel(self.testapp.session) + + def assert_status(status, expected): + with db_transaction.manager: + transaction = transaction_model.get(self.transaction_guid) + transaction.status = status + self.testapp.session.add(transaction) + + res = self.testapp.get( + '/v1/transactions/{}'.format(self.transaction_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=200, + ) + transaction = transaction_model.get(self.transaction_guid) + self.assertEqual(res.json['status'], expected) + + assert_status(transaction_model.STATUS_INIT, 'init') + assert_status(transaction_model.STATUS_RETRYING, 'retrying') + assert_status(transaction_model.STATUS_FAILED, 'failed') + assert_status(transaction_model.STATUS_DONE, 'done') + + def test_get_non_existing_transaction(self): + self.testapp.get( + '/v1/transactions/NON_EXIST', + extra_environ=dict(REMOTE_USER=self.api_key), + status=404 + ) + + def test_get_transaction_with_bad_api_key(self): + self.testapp.get( + '/v1/transactions/{}'.format(self.transaction_guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) + + def test_get_transaction_of_other_company(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + company_model = CompanyModel(self.testapp.session) + customer_model = CustomerModel(self.testapp.session) + plan_model = PlanModel(self.testapp.session) + subscription_model = SubscriptionModel(self.testapp.session) + transaction_model = TransactionModel(self.testapp.session) + with db_transaction.manager: + other_company_guid = company_model.create( + processor_key='MOCK_PROCESSOR_KEY', + ) + other_customer_guid = customer_model.create( + company_guid=other_company_guid + ) + other_plan_guid = plan_model.create( + company_guid=other_company_guid, + frequency=plan_model.FREQ_WEEKLY, + plan_type=plan_model.TYPE_CHARGE, + amount=10, + ) + other_subscription_guid = subscription_model.create( + customer_guid=other_customer_guid, + plan_guid=other_plan_guid, + ) + other_transaction_guid = transaction_model.create( + subscription_guid=other_subscription_guid, + transaction_type=transaction_model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + self.testapp.get( + '/v1/transactions/{}'.format(other_transaction_guid), + extra_environ=dict(REMOTE_USER=self.api_key), + status=403, + ) diff --git a/billy/api/resources/group/view.py b/billy/tests/integration/__init__.py similarity index 100% rename from billy/api/resources/group/view.py rename to billy/tests/integration/__init__.py diff --git a/billy/tests/integration/helper.py b/billy/tests/integration/helper.py new file mode 100644 index 0000000..58dabf1 --- /dev/null +++ b/billy/tests/integration/helper.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +import os +import base64 +import unittest + + +class IntegrationTestCase(unittest.TestCase): + + def setUp(self): + from webtest import TestApp + self.target_url = os.environ.get( + 'BILLY_TEST_URL', + 'http://127.0.0.1:6543#requests') + self.processor_key = os.environ.get( + 'BILLY_TEST_PROCESSOR_KEY', + 'ef13dce2093b11e388de026ba7d31e6f') + self.marketplace_uri = os.environ.get( + 'BILLY_TEST_MARKETPLACE_URI', + '/v1/marketplaces/TEST-MP7hkE8rvpbtYu2dlO1jU2wg') + self.testapp = TestApp(self.target_url) + + def make_auth(self, api_key): + """Make a basic authentication header and return + + """ + encoded = base64.b64encode(api_key + ':') + return (b'authorization', b'basic {}'.format(encoded)) diff --git a/billy/tests/integration/test_basic.py b/billy/tests/integration/test_basic.py new file mode 100644 index 0000000..3b63235 --- /dev/null +++ b/billy/tests/integration/test_basic.py @@ -0,0 +1,89 @@ +from __future__ import unicode_literals + +from billy.tests.integration.helper import IntegrationTestCase + + +class TestBasicScenarios(IntegrationTestCase): + + def test_simple_subscription(self): + import balanced + balanced.configure(self.processor_key) + marketplace = balanced.Marketplace.find(self.marketplace_uri) + + # create a card to charge + card = marketplace.create_card( + name='BILLY_INTERGRATION_TESTER', + card_number='5105105105105100', + expiration_month='12', + expiration_year='2020', + security_code='123', + ) + + # create a company + res = self.testapp.post( + '/v1/companies/', + dict(processor_key=self.processor_key), + status=200 + ) + company = res.json + api_key = str(company['api_key']) + + # create a customer + res = self.testapp.post( + '/v1/customers/', + headers=[self.make_auth(api_key)], + status=200 + ) + customer = res.json + self.assertEqual(customer['company_guid'], company['guid']) + + # create a plan + res = self.testapp.post( + '/v1/plans/', + dict( + plan_type='charge', + amount='12.34', + frequency='daily', + ), + headers=[self.make_auth(api_key)], + status=200 + ) + plan = res.json + self.assertEqual(plan['plan_type'], 'charge') + self.assertEqual(plan['amount'], '12.34') + self.assertEqual(plan['frequency'], 'daily') + self.assertEqual(plan['company_guid'], company['guid']) + + # create a subscription + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=customer['guid'], + plan_guid=plan['guid'], + payment_uri=card.uri, + ), + headers=[self.make_auth(api_key)], + status=200 + ) + subscription = res.json + self.assertEqual(subscription['customer_guid'], customer['guid']) + self.assertEqual(subscription['plan_guid'], plan['guid']) + + # transactions + res = self.testapp.get( + '/v1/transactions/', + headers=[self.make_auth(api_key)], + status=200 + ) + transactions = res.json + self.assertEqual(len(transactions['items']), 1) + transaction = res.json['items'][0] + self.assertEqual(transaction['subscription_guid'], subscription['guid']) + self.assertEqual(transaction['status'], 'done') + + debit = balanced.Debit.find(transaction['external_id']) + self.assertEqual(debit.meta['billy.transaction_guid'], transaction['guid']) + self.assertEqual(debit.amount, 1234) + self.assertEqual(debit.status, 'succeeded') + + # TODO: refund it? diff --git a/billy/tests/test_api/__init__.py b/billy/tests/test_api/__init__.py deleted file mode 100644 index 3146d2c..0000000 --- a/billy/tests/test_api/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import unicode_literals - -from base64 import b64encode -import json -import os - -from flask import url_for, Response -import jsonschema -from unittest import TestCase -from werkzeug.test import Client - -from billy.api.app import app -from billy.api.errors import error_definitions -from billy.api.resources import GroupController -from billy import settings - -class ClientResponse(Response): - def json(self): - if self.content_type != 'application/json': - error = 'content_type is not application/json! Got {0} instead.' - raise TypeError(error.format(self.content_type)) - return json.loads(self.data.decode('utf-8')) - - -class TestClient(Client): - def _add_headers(self, user, kwargs): - if user and user.api_key: - kwargs.setdefault('headers', {})['Authorization'] = \ - 'Basic {}'.format(b64encode(':{}'.format(user.api_key))) - return kwargs - - def get(self, url, user=None, *args, **kwargs): - kwargs = self._add_headers(user, kwargs) - return super(self.__class__, self).get(url, *args, **kwargs) - - def post(self, url, user=None, *args, **kwargs): - kwargs = self._add_headers(user, kwargs) - return super(self.__class__, self).post(url, *args, **kwargs) - - def put(self, url, user=None, *args, **kwargs): - kwargs = self._add_headers(user, kwargs) - return super(self.__class__, self).put(url, *args, **kwargs) - - def delete(self, url, user=None, *args, **kwargs): - kwargs = self._add_headers(user, kwargs) - return super(self.__class__, self).delete(url, *args, **kwargs) - - -class BaseTestCase(TestCase): - json_schema_validator = jsonschema.Draft3Validator - - def setUp(self): - super(BaseTestCase, self).setUp() - self.api_key = settings.TEST_API_KEYS[0] - self.auth_headers = { - 'Authorization': 'Basic {}'.format(b64encode( - ':{}'.format(self.api_key))) - } - - self.client = TestClient(app, response_wrapper=ClientResponse) - self.test_users = [ - type(str('group_user_{}'.format(i)), (), {'api_key': value}) for - i, value in enumerate(TEST_API_KEYS)] - self.ctx = app.test_request_context() - self.ctx.push() - for each_user in self.test_users: - self.client.delete(self.url_for(GroupController), user=each_user) - - def url_for(self, controller, **kwargs): - controller = controller.__name__.lower() - return url_for(controller, **kwargs) - - def assertErrorMatches(self, resp, error_expected): - definition = error_definitions[error_expected] - resp_body = resp.json() - self.assertEqual(resp.status_code, definition['status']) - self.assertEqual(resp_body['status'], definition['status']) - self.assertEqual(resp_body['error_message'], - definition['error_message']) - self.assertEqual(resp_body['error_code'], error_expected) - - @classmethod - def schemas_path(cls, file_name): - base_path = os.path.dirname(__file__) - return os.path.join(base_path, '../fixtures/schemas/', file_name) - - @classmethod - def assertSchema(cls, to_check, schema_path): - if isinstance(to_check, ClientResponse): - to_check = to_check.json() - with open(cls.schemas_path(schema_path)) as schema_file: - schema = json.load(schema_file) - cls.json_schema_validator(schema).validate(to_check) diff --git a/billy/tests/test_api/test_coupon.py b/billy/tests/test_api/test_coupon.py deleted file mode 100644 index 64af5f7..0000000 --- a/billy/tests/test_api/test_coupon.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import unicode_literals - -from billy.api.resources import (CustomerController, CustomerIndexController, - CouponIndexController) -from . import BaseTestCase -from billy.tests.fixtures import (sample_customer, sample_customer_2, sample_customer_3, - sample_coupon) - - -class TestCustomers(BaseTestCase): - schema = 'customer.json' - controller = CustomerController - index_controller = CustomerIndexController - - def setUp(self): - super(TestCustomers, self).setUp() - self.url_index = self.url_for(self.index_controller) - self.url_single = lambda customer_id: self.url_for(self.controller, - customer_id=customer_id) - - -class TestCreateCustomer(TestCustomers): - def test_create(self): - # Simple valid creation test - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.assertEqual(resp.status_code, 201) - self.assertSchema(resp, self.schema) - - - def test_create_bad_params(self): - # Test bad customer_id - data = sample_customer.copy() - data['customer_id'] = None - resp = self.client.post(self.url_index, user=self.test_users[0], - data=data) - self.assertErrorMatches(resp, '400_CUSTOMER_ID') - - #Test bad models.processor id: - data = sample_customer.copy() - data['provider_id'] = None - resp = self.client.post(self.url_index, user=self.test_users[0], - data=data) - self.assertErrorMatches(resp, '400_PROVIDER_ID') - - def test_create_collision(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Creating two customer under the same group with the same external id - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.assertErrorMatches(resp, '409_CUSTOMER_ALREADY_EXISTS') - - # Create on different Company. Should work. - resp = self.client.post(self.url_index, user=self.test_users[1], - data=sample_customer) - self.assertEqual(resp.status_code, 201) - - -class TestGetCustomer(TestCustomers): - def test_get(self): - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - second_resp = self.client.get( - self.url_single(sample_customer['customer_id']), - user=self.test_users[0]) - - # Make sure two responses match: - self.assertEqual(resp.json(), second_resp.json()) - - # Make sure second group can't retrieve first. - resp = self.client.get( - self.url_single(sample_customer['customer_id']), - user=self.test_users[1]) - self.assertErrorMatches(resp, '404_CUSTOMER_NOT_FOUND') - - - def test_get_list(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer_2) - self.client.post(self.url_index, user=self.test_users[1], - data=sample_customer_3) - - # Make sure group 1 only has 2 users - resp = self.client.get(self.url_index, user=self.test_users[0]) - self.assertEqual(len(resp.json()), 2) - for item in resp.json(): - self.assertSchema(item, self.schema) - - # Make sure group 2 only has 1 user - resp = self.client.get(self.url_index, user=self.test_users[1]) - self.assertEqual(len(resp.json()), 1) - for item in resp.json(): - self.assertSchema(item, self.schema) - - -class TestUpdate(TestCustomers): - def test_update(self): - # Create a customer - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Create a coupon - coupon_url = self.url_for(CouponIndexController) - resp = self.client.post(coupon_url, user=self.test_users[0], - data=sample_coupon) - self.assertEqual(resp.status_code, 200) - # Update with an existing coupon - data = {'coupon_id': sample_coupon['coupon_id']} - resp1 = self.client.put(self.url_single(sample_customer['customer_id']), - data=data, - user=self.test_users[0]) - self.assertEqual(resp1.status_code, 200) - - # Make sure the coupon is now attached: - resp2 = self.client.get(self.url_single(sample_customer['customer_id']), - user=self.test_users[0]) - self.assertEqual(resp2.json()['current_coupon'], - sample_coupon['coupon_id']) - - def test_update_bad_params(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Coupon DNE - put_data = {'coupon_id': 'coupon_dne'} - resp = self.client.put( - self.url_single(sample_customer['customer_id']), data=put_data, - user=self.test_users[0]) - self.assertErrorMatches(resp, '404_COUPON_NOT_FOUND') - - - # Apply another groups coupon - coupon_url = self.url_for(CouponIndexController) - resp = self.client.post(coupon_url, user=self.test_users[1], - data=sample_coupon) - self.assertEqual(resp.status_code, 200) - data = {'coupon_id': sample_coupon['coupon_id']} - resp1 = self.client.put(self.url_single(sample_customer['customer_id']), - data=data, - user=self.test_users[0]) - self.assertErrorMatches(resp1, '404_COUPON_NOT_FOUND') \ No newline at end of file diff --git a/billy/tests/test_api/test_customer.py b/billy/tests/test_api/test_customer.py deleted file mode 100644 index 41d7419..0000000 --- a/billy/tests/test_api/test_customer.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import unicode_literals - -from billy.api.resources import (CustomerController, CustomerIndexController, - CouponIndexController) -from . import BaseTestCase -from billy.tests.fixtures import (sample_customer, sample_customer_2, sample_customer_3, - sample_coupon) - - -class TestCustomers(BaseTestCase): - schema = 'customer.json' - controller = CustomerController - index_controller = CustomerIndexController - - def setUp(self): - super(TestCustomers, self).setUp() - self.url_index = self.url_for(self.index_controller) - self.url_single = lambda customer_id: self.url_for(self.controller, - customer_id=customer_id) - - -class TestCreateCustomer(TestCustomers): - def test_create(self): - # Simple valid creation test - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.assertEqual(resp.status_code, 201) - self.assertSchema(resp, self.schema) - - - def test_create_bad_params(self): - # Test bad customer_id - data = sample_customer.copy() - data['customer_id'] = None - resp = self.client.post(self.url_index, user=self.test_users[0], - data=data) - self.assertErrorMatches(resp, '400_CUSTOMER_ID') - - #Test bad models.processor id: - data = sample_customer.copy() - data['provider_id'] = None - resp = self.client.post(self.url_index, user=self.test_users[0], - data=data) - self.assertErrorMatches(resp, '400_PROVIDER_ID') - - def test_create_collision(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Creating two customer under the same group with the same external id - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.assertErrorMatches(resp, '409_CUSTOMER_ALREADY_EXISTS') - - # Create on different Company. Should work. - resp = self.client.post(self.url_index, user=self.test_users[1], - data=sample_customer) - self.assertEqual(resp.status_code, 201) - - -class TestGetCustomer(TestCustomers): - def test_get(self): - resp = self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - second_resp = self.client.get( - self.url_single(sample_customer['customer_id']), - user=self.test_users[0]) - - # Make sure two responses match: - self.assertEqual(resp.json(), second_resp.json()) - - # Make sure second group can't retrieve first. - resp = self.client.get( - self.url_single(sample_customer['customer_id']), - user=self.test_users[1]) - self.assertErrorMatches(resp, '404_CUSTOMER_NOT_FOUND') - - - def test_get_list(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer_2) - self.client.post(self.url_index, user=self.test_users[1], - data=sample_customer_3) - - # Make sure group 1 only has 2 users - resp = self.client.get(self.url_index, user=self.test_users[0]) - self.assertEqual(len(resp.json()), 2) - for item in resp.json(): - self.assertSchema(item, self.schema) - - # Make sure group 2 only has 1 user - resp = self.client.get(self.url_index, user=self.test_users[1]) - self.assertEqual(len(resp.json()), 1) - for item in resp.json(): - self.assertSchema(item, self.schema) - - -class TestUpdate(TestCustomers): - def test_update(self): - # Create a customer - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Create a coupon - coupon_url = self.url_for(CouponIndexController) - resp = self.client.post(coupon_url, user=self.test_users[0], - data=sample_coupon) - self.assertEqual(resp.status_code, 200) - # Update with an existing coupon - data = {'coupon_id': sample_coupon['coupon_id']} - resp1 = self.client.put(self.url_single(sample_customer['customer_id']), - data=data, - user=self.test_users[0]) - self.assertEqual(resp1.status_code, 200) - - # Make sure the coupon is now attached: - resp2 = self.client.get(self.url_single(sample_customer['customer_id']), - user=self.test_users[0]) - self.assertEqual(resp2.json()['current_coupon'], - sample_coupon['coupon_id']) - - def test_update_bad_params(self): - self.client.post(self.url_index, user=self.test_users[0], - data=sample_customer) - - # Coupon DNE - put_data = {'coupon_id': 'coupon_dne'} - resp = self.client.put( - self.url_single(sample_customer['customer_id']), data=put_data, - user=self.test_users[0]) - self.assertErrorMatches(resp, '404_COUPON_NOT_FOUND') - - - # Apply another groups coupon - coupon_url = self.url_for(CouponIndexController) - resp = self.client.post(coupon_url, user=self.test_users[1], - data=sample_coupon) - self.assertEqual(resp.status_code, 200) - data = {'coupon_id': sample_coupon['coupon_id']} - resp1 = self.client.put(self.url_single(sample_customer['customer_id']), - data=data, - user=self.test_users[0]) - self.assertErrorMatches(resp1, '404_COUPON_NOT_FOUND') \ No newline at end of file diff --git a/billy/tests/test_api/test_group_auth.py b/billy/tests/test_api/test_group_auth.py deleted file mode 100644 index a7c8b53..0000000 --- a/billy/tests/test_api/test_group_auth.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import unicode_literals - -from base64 import b64encode - -from . import BaseTestCase - - -class GroupAuthenticationTest(BaseTestCase): - - def setUp(self): - self.bad_auth_headers = { - 'Authorization': "Basic {}".format(b64encode(':BADAPIKEY')) - } - super(GroupAuthenticationTest, self).setUp() - - def test_no_key(self): - resp = self.client.get('/auth/') - self.assertEqual(resp.status_code, 401) - - def test_bad_auth_key(self): - resp = self.client.get('/auth/', - headers=self.bad_auth_headers) - self.assertEqual(resp.status_code, 401) - - def test_good_auth_key(self): - resp = self.client.get('/auth/', - headers=self.auth_headers) - self.assertEqual(resp.status_code, 200) - - def test_key_in_header(self): - resp = self.client.get('/auth/', - headers={'Authorization': self.api_key}) - self.assertEqual(resp.status_code, 200) - resp = self.client.get('/auth/', - headers={'Authorization': 'BADKEY'}) - self.assertEqual(resp.status_code, 401) - - def test_key_in_get(self): - resp = self.client.get('/auth/', - query_string={'api_key': self.api_key}) - self.assertEqual(resp.status_code, 200) - resp = self.client.get('/auth/', - query_string={'api_key': 'BADKEY'}) - self.assertEqual(resp.status_code, 401) diff --git a/billy/tests/test_models/test_charge_invoice.py b/billy/tests/test_models/test_charge_invoice.py deleted file mode 100644 index 1763762..0000000 --- a/billy/tests/test_models/test_charge_invoice.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from freezegun import freeze_time - -from billy.models import ChargePlanInvoice, ChargeSubscription -from billy.utils.intervals import Intervals -from billy.tests import BaseTestCase, fixtures - - -class ChargeInvoiceTest(BaseTestCase): - def setUp(self): - super(ChargeInvoiceTest, self).setUp() - # Prune old companies - self.company = self.test_companies[0] - - # Create a plan under the company - self.plan = self.company.create_charge_plan(**fixtures.sample_plan()) - - # Create a customer under the company - self.customer = self.company.create_customer(**fixtures.sample_customer()) - - # Create a coupon under the company - self.coupon = self.company.create_coupon(**fixtures.sample_coupon()) - - - def basic_test(self): - # Lets go to the future - with freeze_time('2014-01-01'): - # Subscribe the customer to the plan using that coupon - sub = self.plan.subscribe(self.customer, quantity=1, - coupon=self.coupon) - - # Subscription will now renew and the person is enrolled - self.assertTrue(sub.is_enrolled) - self.assertTrue(sub.should_renew) - # An current invoice is generated for that user - invoice = sub.current_invoice - - # 10% off coupon: - self.assertEqual(invoice.remaining_balance_cents, 900) - - # None of its paid yet: - self.assertEqual(invoice.amount_paid_cents, 0) - - # Invoice should start now: - self.assertEqual(invoice.start_dt, datetime.utcnow()) - - # But it should be due in a week because of the trial - self.assertTrue(invoice.includes_trial) - self.assertEqual(invoice.due_dt, datetime.utcnow() + Intervals.WEEK) - - # Moving to when that invoice is due: - with freeze_time('2014-01-08'): - all_due = ChargePlanInvoice.all_due(self.customer) - - # Should have one due - self.assertEqual(len(all_due), 1) - self.assertEqual(all_due[0], invoice) - - # Run the task to settle all invoices: - ChargePlanInvoice.settle_all() - - # Should no longer be due - all_due_new = ChargePlanInvoice.all_due(self.customer) - - # There should be a transaction with the processor: - transaction = invoice.transaction - self.assertTrue(transaction) - self.assertEqual(transaction.amount_cents, 900) - - - - # Moving to when the next invoice should be generated (1 month + 1 week for trial): - with freeze_time('2014-02-09'): - # Shouldn't have a current_invoice - self.assertIsNone(sub.current_invoice) - - # Lets generate all the next invoices: - count_invoices_generated = ChargeSubscription.generate_all_invoices() - self.assertEqual(count_invoices_generated, 1) - - # A new invoice should be generated - new_invoice = sub.current_invoice - # With a start_dt the same as the last ones end_dt - self.assertEqual(new_invoice.start_dt, invoice.end_dt) - - # No longer a trial - self.assertFalse(new_invoice.includes_trial) - # So it should end in a month: - self.assertEqual(new_invoice.end_dt, - new_invoice.start_dt + Intervals.MONTH) - - - - - - diff --git a/billy/tests/test_models/test_charge_plan.py b/billy/tests/test_models/test_charge_plan.py deleted file mode 100644 index faa0c3e..0000000 --- a/billy/tests/test_models/test_charge_plan.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import unicode_literals - -from freezegun import freeze_time - -from billy.utils.intervals import Intervals -from billy.tests import BaseTestCase, fixtures - - -class ChargePlanTest(BaseTestCase): - def setUp(self): - super(ChargePlanTest, self).setUp() - self.company = self.test_companies[0] - - self.customer = self.company.create_customer( - **fixtures.sample_customer()) - - def basic_test(self): - # Create the plan - server_plan = self.company.create_charge_plan( - your_id='BIG_SERVER', # What you call the plan - name='The Big Server', # Display name - price_cents=1000, # $10 - plan_interval=Intervals.MONTH, # Monthly plan - trial_interval=Intervals.WEEK # 1 week trial - ) - ip_plan = self.company.create_charge_plan( - your_id='IPs', - name='Daily IPs', - price_cents=1000, - plan_interval=Intervals.DAY, - trial_interval=None - ) - - with freeze_time('2014-01-01'): - # Subscribe the customer to the plan - sub = server_plan.subscribe(self.customer, quantity=1) - - # We can subscribe to multiple plans at different times - with freeze_time('2014-01-05'): - # Lets give subscribe to some IPs for our server now too - # with 5 IPs - sub2 = ip_plan.subscribe(self.customer, quantity=5) - - # Modifying a subscription is as easy as resubscribing - with freeze_time('2014-01-10'): - # Lets up them to 10 IPs - sub2 = ip_plan.subscribe(self.customer, quantity=10) - - self.assertEqual(len(self.customer.charge_subscriptions), 2) diff --git a/billy/tests/test_models/test_charge_subscription.py b/billy/tests/test_models/test_charge_subscription.py deleted file mode 100644 index 1dacfe0..0000000 --- a/billy/tests/test_models/test_charge_subscription.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import unicode_literals - -from freezegun import freeze_time - -from billy.tests import BaseTestCase, fixtures - - -class ChargePlanSubscriptionTest(BaseTestCase): - def setUp(self): - super(ChargePlanSubscriptionTest, self).setUp() - self.company = self.test_companies[0] - - self.customer = self.company.create_customer( - **fixtures.sample_customer()) - - self.plan = self.company.create_charge_plan( - **fixtures.sample_plan(trial_interval=None)) - - def basic_test(self): - with freeze_time('2014-01-01'): - # Subscribe the customer to the plan - sub = self.plan.subscribe(self.customer, quantity=1) - invoice = sub.current_invoice - - with freeze_time('2014-01-05'): - # Wont do anything since there is a current invoice - sub.generate_next_invoice() - self.assertEqual(sub.current_invoice, invoice) - - with freeze_time('2014-02-01'): - # The task the generates the next invoice for the subscription - invoice_new = sub.generate_next_invoice() - self.assertNotEqual(invoice, invoice_new) - - with freeze_time('2014-02-15'): - # We can safely cancel a subscription half way which will prorate - # it automatically for us. - sub.cancel() diff --git a/billy/tests/test_models/test_charge_transaction.py b/billy/tests/test_models/test_charge_transaction.py deleted file mode 100644 index 7c18607..0000000 --- a/billy/tests/test_models/test_charge_transaction.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import unicode_literals - -from freezegun import freeze_time - -from billy.models import ChargePlanInvoice, ChargeTransactionStatus -from billy.tests import BaseTestCase, fixtures - - -class ChargeTransactionTest(BaseTestCase): - def setUp(self): - super(ChargeTransactionTest, self).setUp() - self.company = self.test_companies[0] - - self.customer = self.company.create_customer( - **fixtures.sample_customer()) - - self.plan = self.company.create_charge_plan( - **fixtures.sample_plan(trial_interval=None)) - - def basic_test(self): - with freeze_time('2014-01-01'): - # Subscribe the customer to the plan - sub = self.plan.subscribe(self.customer, quantity=1) - invoice = sub.current_invoice - - with freeze_time('2014-01-02'): - count_settled = ChargePlanInvoice.settle_all() - self.assertEqual(count_settled, 1) - invoice = sub.invoices.first() - # The transaction - transaction = invoice.transaction - self.assertEqual(invoice.remaining_balance_cents, 0) - self.assertEqual(invoice.amount_paid_cents, - transaction.amount_cents) - - # The transaction will have a sent status - self.assertEqual(transaction.status, ChargeTransactionStatus.SENT) \ No newline at end of file diff --git a/billy/tests/test_models/test_company.py b/billy/tests/test_models/test_company.py deleted file mode 100644 index 1215b99..0000000 --- a/billy/tests/test_models/test_company.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from billy.models import Company, ProcessorType -from billy.models.processor import DummyProcessor -from billy.tests import BaseTestCase, fixtures - - -class CompanyTest(BaseTestCase): - def setUp(self): - super(CompanyTest, self).setUp() - - - def basic_test(self): - # Create a company - company = Company.create( - processor_type=ProcessorType.DUMMY, # Dummy processor, - processor_credential="API_KEY_WITH_PROCESSOR", - is_test=True, # Allows us to delete it! - ) - - # Primary functionality - company.create_coupon(**fixtures.sample_coupon()) - company.create_customer(**fixtures.sample_customer()) - company.create_charge_plan(**fixtures.sample_plan()) - company.create_payout_plan(**fixtures.sample_payout()) - - - # Retrieving the instantiated processor class - processor_class = company.processor - self.assertIsInstance(processor_class, DummyProcessor) - - # Performing transactions, generally though these should be preferred - # by some application logic. - processor_class.check_balance('SOME_CUSTOMER_ID') - - - # Since its a test company we can now delete it: - company.session.commit() - company.delete() - - - - - - - - - diff --git a/billy/tests/test_models/test_coupon.py b/billy/tests/test_models/test_coupon.py deleted file mode 100644 index f22c6ef..0000000 --- a/billy/tests/test_models/test_coupon.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import unicode_literals -from datetime import datetime - -from freezegun import freeze_time - -from billy.models import ChargeSubscription -from billy.tests import BaseTestCase, fixtures - - -class CouponTest(BaseTestCase): - def setUp(self): - super(CouponTest, self).setUp() - self.company = self.test_companies[0] - self.customer = self.company.create_customer( - **fixtures.sample_customer()) - self.plan = self.company.create_charge_plan( - **fixtures.sample_plan(trial_interval=None)) - - - def basic_test(self): - # Create the coupon - coupon = self.company.create_coupon( - your_id='10_OFF_COUPON', - name='First Invoice 10 off', - price_off_cents=0, - percent_off_int=10, - max_redeem=-1, # Maximum users on the coupon - repeating=2, - expire_at=datetime(year=2014, month=1, day=20) - - ) - with freeze_time('2014-01-01'): - sub = self.plan.subscribe(self.customer, quantity=1, - coupon=coupon) - # Current invoice should be using the coupon - invoice = sub.current_invoice - self.assertEqual(invoice.coupon, coupon) - - - - # Shouldn't work since its expired. - with freeze_time('2014-2-1'): - with self.assertRaises(ValueError): - self.plan.subscribe(self.customer, quantity=10, coupon=coupon) - - # Should use coupon since its attached to the subscription: - with freeze_time('2014-02-2'): - ChargeSubscription.generate_all_invoices() - next_invoice = sub.current_invoice - self.assertEqual(next_invoice.coupon, coupon) - self.assertNotEqual(invoice, next_invoice) - diff --git a/billy/tests/test_models/test_interface.py b/billy/tests/test_models/test_interface.py deleted file mode 100644 index 53faa4a..0000000 --- a/billy/tests/test_models/test_interface.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import unicode_literals - -from billy.models import Company -from billy.tests import BaseTestCase -from billy.tests.fixtures import (sample_company, sample_plan, sample_coupon, - sample_customer, sample_payout) - - -class ChargePlanInterfaceTest(BaseTestCase): - def main_test(self): - company = Company.create(**sample_company()) - - # Create A Plan under the company - plan = company.create_charge_plan(**sample_plan()) - - - #Create A Coupon under the company - coupon = company.create_coupon(**sample_coupon()) - - - # Create A customer under the company: - customer = company.create_customer(**sample_customer()) - - - # Subscribe Customer to a plan - sub = plan.subscribe(customer, quantity=1, coupon=coupon) - - # Unsubscribe Customer from plan: - sub.cancel() - - # Delete the test Company - company.delete() - - -class ChargePayoutInterfaceTest(BaseTestCase): - def main_test(self): - company = Company.create(**sample_company()) - # Create A Payout under the company - payout = company.create_payout_plan(**sample_payout()) - - - # Create A customer under the company: - customer = company.create_customer(**sample_customer()) - - # Subscribe Customer to a payout - sub = payout.subscribe(customer) - - # Unsubscribe Customer from payout: - sub.cancel() - - # Delete the test company - company.delete() diff --git a/billy/settings/prod.py b/billy/tests/unit/__init__.py similarity index 100% rename from billy/settings/prod.py rename to billy/tests/unit/__init__.py diff --git a/billy/tests/unit/helper.py b/billy/tests/unit/helper.py new file mode 100644 index 0000000..74f8626 --- /dev/null +++ b/billy/tests/unit/helper.py @@ -0,0 +1,46 @@ +from __future__ import unicode_literals +import os +import unittest +import datetime + + +def create_session(echo=False): + """Create engine and session for testing, return session then + + """ + # NOTICE: we do all imports here because we don't want to + # expose too many third party imports to testing modules. + # As we want to do imports mainly in test cases. + # In that way, import error can be captured and it won't + # break the whole test module + from sqlalchemy import create_engine + from sqlalchemy.orm import scoped_session + from sqlalchemy.orm import sessionmaker + from zope.sqlalchemy import ZopeTransactionExtension + from billy.models.tables import DeclarativeBase + db_url = os.environ.get('BILLY_UNIT_TEST_DB', 'sqlite:///') + engine = create_engine(db_url, convert_unicode=True, echo=echo) + DeclarativeBase.metadata.bind = engine + DeclarativeBase.metadata.create_all() + + DBSession = scoped_session(sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + extension=ZopeTransactionExtension() + )) + return DBSession + + +class ModelTestCase(unittest.TestCase): + + def setUp(self): + from billy.models import tables + self.session = create_session() + self._old_now_func = tables.set_now_func(datetime.datetime.utcnow) + + def tearDown(self): + from billy.models import tables + self.session.remove() + tables.DeclarativeBase.metadata.drop_all() + tables.set_now_func(self._old_now_func) diff --git a/billy/tests/unit/test_models/__init__.py b/billy/tests/unit/test_models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billy/tests/unit/test_models/test_company.py b/billy/tests/unit/test_models/test_company.py new file mode 100644 index 0000000..3c28d95 --- /dev/null +++ b/billy/tests/unit/test_models/test_company.py @@ -0,0 +1,174 @@ +from __future__ import unicode_literals +import datetime + +import transaction +from freezegun import freeze_time +from flexmock import flexmock + +from billy.tests.unit.helper import ModelTestCase + + +@freeze_time('2013-08-16') +class TestCompanyModel(ModelTestCase): + + def make_one(self, *args, **kwargs): + from billy.models.company import CompanyModel + return CompanyModel(*args, **kwargs) + + def test_get(self): + model = self.make_one(self.session) + + company = model.get('CP_NON_EXIST') + self.assertEqual(company, None) + + with self.assertRaises(KeyError): + model.get('CP_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + model.delete(guid) + + with self.assertRaises(KeyError): + model.get(guid, raise_error=True) + + company = model.get(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(company.guid, guid) + + def test_get_by_api_key(self): + model = self.make_one(self.session) + + company = model.get_by_api_key('NON_EXIST_API') + self.assertEqual(company, None) + + with self.assertRaises(KeyError): + model.get_by_api_key('NON_EXIST_API', raise_error=True) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + company = model.get(guid) + api_key = company.api_key + model.delete(guid) + + with self.assertRaises(KeyError): + model.get_by_api_key(api_key, raise_error=True) + + company = model.get_by_api_key(api_key, ignore_deleted=False, raise_error=True) + self.assertEqual(company.guid, guid) + + def test_create(self): + model = self.make_one(self.session) + name = 'awesome company' + processor_key = 'my_secret_key' + + with transaction.manager: + guid = model.create( + name=name, + processor_key=processor_key, + ) + + now = datetime.datetime.utcnow() + + company = model.get(guid) + self.assertEqual(company.guid, guid) + self.assert_(company.guid.startswith('CP')) + self.assertEqual(company.name, name) + self.assertEqual(company.processor_key, processor_key) + self.assertNotEqual(company.api_key, None) + self.assertEqual(company.deleted, False) + self.assertEqual(company.created_at, now) + self.assertEqual(company.updated_at, now) + + def test_create_different_created_updated_time(self): + from billy.models import tables + model = self.make_one(self.session) + + results = [ + datetime.datetime(2013, 8, 16, 1), + datetime.datetime(2013, 8, 16, 2), + ] + + def mock_utcnow(): + return results.pop(0) + + tables.set_now_func(mock_utcnow) + + with transaction.manager: + guid = model.create('my_secret_key') + + company = model.get(guid) + self.assertEqual(company.created_at, company.updated_at) + + def test_update(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + + name = 'new name' + processor_key = 'new processor key' + api_key = 'new api key' + + with transaction.manager: + model.update( + guid=guid, + name=name, + api_key=api_key, + processor_key=processor_key, + ) + + company = model.get(guid) + self.assertEqual(company.name, name) + self.assertEqual(company.processor_key, processor_key) + self.assertEqual(company.api_key, api_key) + + def test_update_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + + company = model.get(guid) + created_at = company.created_at + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with transaction.manager: + model.update(guid=guid) + updated_at = datetime.datetime.utcnow() + + company = model.get(guid) + self.assertEqual(company.updated_at, updated_at) + self.assertEqual(company.created_at, created_at) + + # advanced the current date time even more + with freeze_time('2013-08-16 08:35:40'): + with transaction.manager: + model.update(guid) + updated_at = datetime.datetime.utcnow() + + company = model.get(guid) + self.assertEqual(company.updated_at, updated_at) + self.assertEqual(company.created_at, created_at) + + def test_update_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update(guid, wrong_arg=True, neme='john') + + def test_delete(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(processor_key='my_secret_key') + model.delete(guid) + + company = model.get(guid) + self.assertEqual(company, None) + + company = model.get(guid, ignore_deleted=False) + self.assertEqual(company.deleted, True) diff --git a/billy/tests/unit/test_models/test_customer.py b/billy/tests/unit/test_models/test_customer.py new file mode 100644 index 0000000..808b031 --- /dev/null +++ b/billy/tests/unit/test_models/test_customer.py @@ -0,0 +1,157 @@ +from __future__ import unicode_literals +import datetime + +import transaction +from freezegun import freeze_time + +from billy.tests.unit.helper import ModelTestCase + + +@freeze_time('2013-08-16') +class TestCustomerModel(ModelTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + super(TestCustomerModel, self).setUp() + # build the basic scenario for plan model + self.company_model = CompanyModel(self.session) + with transaction.manager: + self.company_guid = self.company_model.create('my_secret_key') + + def make_one(self, *args, **kwargs): + from billy.models.customer import CustomerModel + return CustomerModel(*args, **kwargs) + + def test_get_customer(self): + model = self.make_one(self.session) + + customer = model.get('PL_NON_EXIST') + self.assertEqual(customer, None) + + with self.assertRaises(KeyError): + model.get('PL_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create(company_guid=self.company_guid) + model.delete(guid) + + with self.assertRaises(KeyError): + model.get(guid, raise_error=True) + + customer = model.get(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(customer.guid, guid) + + def test_create(self): + model = self.make_one(self.session) + external_id = '5566_GOOD_BROTHERS' + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + external_id=external_id, + ) + + now = datetime.datetime.utcnow() + + customer = model.get(guid) + self.assertEqual(customer.guid, guid) + self.assert_(customer.guid.startswith('CU')) + self.assertEqual(customer.company_guid, self.company_guid) + self.assertEqual(customer.external_id, external_id) + self.assertEqual(customer.deleted, False) + self.assertEqual(customer.created_at, now) + self.assertEqual(customer.updated_at, now) + + def test_create_different_created_updated_time(self): + from billy.models import tables + model = self.make_one(self.session) + + results = [ + datetime.datetime(2013, 8, 16, 1), + datetime.datetime(2013, 8, 16, 2), + ] + + def mock_utcnow(): + return results.pop(0) + + tables.set_now_func(mock_utcnow) + + with transaction.manager: + guid = model.create(self.company_guid) + + customer = model.get(guid) + self.assertEqual(customer.created_at, customer.updated_at) + + def test_update(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + external_id='old id', + ) + + customer = model.get(guid) + external_id = 'new external id' + + with transaction.manager: + model.update( + guid=guid, + external_id=external_id, + ) + + customer = model.get(guid) + self.assertEqual(customer.external_id, external_id) + + def test_update_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(company_guid=self.company_guid) + + customer = model.get(guid) + created_at = customer.created_at + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with transaction.manager: + model.update(guid=guid) + updated_at = datetime.datetime.utcnow() + + customer = model.get(guid) + self.assertEqual(customer.updated_at, updated_at) + self.assertEqual(customer.created_at, created_at) + + # advanced the current date time even more + with freeze_time('2013-08-16 08:35:40'): + # this should update the updated_at field only + with transaction.manager: + model.update(guid) + updated_at = datetime.datetime.utcnow() + + customer = model.get(guid) + self.assertEqual(customer.updated_at, updated_at) + self.assertEqual(customer.created_at, created_at) + + def test_update_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(company_guid=self.company_guid) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update(guid, wrong_arg=True, neme='john') + + def test_delete(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create(company_guid=self.company_guid) + model.delete(guid) + + customer = model.get(guid) + self.assertEqual(customer, None) + + customer = model.get(guid, ignore_deleted=False) + self.assertEqual(customer.deleted, True) diff --git a/billy/tests/unit/test_models/test_plan.py b/billy/tests/unit/test_models/test_plan.py new file mode 100644 index 0000000..ea2133a --- /dev/null +++ b/billy/tests/unit/test_models/test_plan.py @@ -0,0 +1,272 @@ +from __future__ import unicode_literals +import datetime +import decimal + +import transaction +from freezegun import freeze_time + +from billy.tests.unit.helper import ModelTestCase + + +@freeze_time('2013-08-16') +class TestPlanModel(ModelTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + super(TestPlanModel, self).setUp() + # build the basic scenario for plan model + self.company_model = CompanyModel(self.session) + with transaction.manager: + self.company_guid = self.company_model.create('my_secret_key') + + def make_one(self, *args, **kwargs): + from billy.models.plan import PlanModel + return PlanModel(*args, **kwargs) + + def test_get_plan(self): + model = self.make_one(self.session) + + plan = model.get('PL_NON_EXIST') + self.assertEqual(plan, None) + + with self.assertRaises(KeyError): + model.get('PL_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name='name', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + model.delete(guid) + + with self.assertRaises(KeyError): + model.get(guid, raise_error=True) + + plan = model.get(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(plan.guid, guid) + + def test_create(self): + model = self.make_one(self.session) + name = 'monthly billing to user John' + amount = decimal.Decimal('5566.77') + frequency = model.FREQ_MONTHLY + plan_type = model.TYPE_CHARGE + interval = 5 + external_id = '5566_GOOD_BROTHERS' + description = 'This is a long description' + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=plan_type, + name=name, + amount=amount, + frequency=frequency, + interval=interval, + external_id=external_id, + description=description, + ) + + now = datetime.datetime.utcnow() + + plan = model.get(guid) + self.assertEqual(plan.guid, guid) + self.assert_(plan.guid.startswith('PL')) + self.assertEqual(plan.company_guid, self.company_guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.amount, amount) + self.assertEqual(plan.frequency, frequency) + self.assertEqual(plan.interval, interval) + self.assertEqual(plan.plan_type, plan_type) + self.assertEqual(plan.external_id, external_id) + self.assertEqual(plan.description, description) + self.assertEqual(plan.deleted, False) + self.assertEqual(plan.created_at, now) + self.assertEqual(plan.updated_at, now) + + def test_create_different_created_updated_time(self): + from billy.models import tables + model = self.make_one(self.session) + + results = [ + datetime.datetime(2013, 8, 16, 1), + datetime.datetime(2013, 8, 16, 2), + ] + + def mock_utcnow(): + return results.pop(0) + + tables.set_now_func(mock_utcnow) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + amount=999, + frequency=model.FREQ_MONTHLY, + ) + + plan = model.get(guid) + self.assertEqual(plan.created_at, plan.updated_at) + + def test_create_with_zero_interval(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name=None, + amount=999, + frequency=model.FREQ_MONTHLY, + interval=0, + ) + + def test_create_with_negtive_interval(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name=None, + amount=999, + frequency=model.FREQ_MONTHLY, + interval=-1, + ) + + def test_create_with_wrong_frequency(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name=None, + amount=999, + frequency=999, + ) + + def test_create_with_wrong_type(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + company_guid=self.company_guid, + plan_type=999, + name=None, + amount=999, + frequency=model.FREQ_DAILY, + ) + + def test_update(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name='old name', + amount=99.99, + frequency=model.FREQ_WEEKLY, + description='old description', + external_id='old external id', + ) + + plan = model.get(guid) + name = 'new name' + description = 'new description' + external_id = 'new external id' + + with transaction.manager: + model.update( + guid=guid, + name=name, + description=description, + external_id=external_id, + ) + + plan = model.get(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.description, description) + self.assertEqual(plan.external_id, external_id) + + def test_update_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name='evil gangster charges protection fee from Tom weekly', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + + plan = model.get(guid) + created_at = plan.created_at + name = 'new plan name' + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with transaction.manager: + model.update( + guid=guid, + name=name, + ) + updated_at = datetime.datetime.utcnow() + + plan = model.get(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.updated_at, updated_at) + self.assertEqual(plan.created_at, created_at) + + # advanced the current date time even more + with freeze_time('2013-08-16 08:35:40'): + # this should update the updated_at field only + with transaction.manager: + model.update(guid) + updated_at = datetime.datetime.utcnow() + + plan = model.get(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.updated_at, updated_at) + self.assertEqual(plan.created_at, created_at) + + def test_update_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name='evil gangster charges protection fee from Tom weekly', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update(guid, wrong_arg=True, neme='john') + + def test_delete(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create( + company_guid=self.company_guid, + plan_type=model.TYPE_CHARGE, + name='name', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + model.delete(guid) + + plan = model.get(guid) + self.assertEqual(plan, None) + + plan = model.get(guid, ignore_deleted=False) + self.assertEqual(plan.deleted, True) diff --git a/billy/tests/unit/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py new file mode 100644 index 0000000..ac51431 --- /dev/null +++ b/billy/tests/unit/test_models/test_processors.py @@ -0,0 +1,577 @@ +from __future__ import unicode_literals +import unittest +import datetime + +import transaction as db_transaction +from flexmock import flexmock +from freezegun import freeze_time + +from billy.tests.unit.helper import ModelTestCase + + +class TestPaymentProcessorModel(unittest.TestCase): + + def test_base_processor(self): + from billy.models.processors.base import PaymentProcessor + processor = PaymentProcessor() + with self.assertRaises(NotImplementedError): + processor.create_customer(None) + with self.assertRaises(NotImplementedError): + processor.prepare_customer(None) + with self.assertRaises(NotImplementedError): + processor.charge(None) + with self.assertRaises(NotImplementedError): + processor.payout(None) + with self.assertRaises(NotImplementedError): + processor.refund(None) + + +@freeze_time('2013-08-16') +class TestBalancedProcessorModel(ModelTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + from billy.models.subscription import SubscriptionModel + from billy.models.transaction import TransactionModel + super(TestBalancedProcessorModel, self).setUp() + # build the basic scenario for transaction model + self.company_model = CompanyModel(self.session) + self.customer_model = CustomerModel(self.session) + self.plan_model = PlanModel(self.session) + self.subscription_model = SubscriptionModel(self.session) + self.transaction_model = TransactionModel(self.session) + with db_transaction.manager: + self.company_guid = self.company_model.create('my_secret_key') + self.plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + self.customer_guid = self.customer_model.create( + company_guid=self.company_guid, + ) + self.subscription_guid = self.subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + payment_uri='/v1/credit_card/tester', + ) + + def make_one(self, *args, **kwargs): + from billy.models.processors.balanced_payments import BalancedProcessor + return BalancedProcessor(*args, **kwargs) + + def test_create_customer(self): + import balanced + + customer = self.customer_model.get(self.customer_guid) + + # make sure API key is set correctly + ( + flexmock(balanced) + .should_receive('configure') + .with_args('my_secret_key') + .once() + ) + + # mock balanced customer instance + mock_balanced_customer = ( + flexmock(uri='MOCK_BALANCED_CUSTOMER_URI') + .should_receive('save') + .replace_with(lambda: mock_balanced_customer) + .once() + .mock() + ) + + class BalancedCustomer(object): + pass + flexmock(BalancedCustomer).new_instances(mock_balanced_customer) + + processor = self.make_one(customer_cls=BalancedCustomer) + customer_id = processor.create_customer(customer) + self.assertEqual(customer_id, 'MOCK_BALANCED_CUSTOMER_URI') + + def test_prepare_customer_with_card(self): + import balanced + + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_URI', + ) + customer = self.customer_model.get(self.customer_guid) + + # make sure API key is set correctly + ( + flexmock(balanced) + .should_receive('configure') + .with_args('my_secret_key') + .once() + ) + + # mock balanced.Customer instance + mock_balanced_customer = ( + flexmock() + .should_receive('add_card') + .with_args('/v1/cards/my_card') + .once() + .mock() + ) + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, uri): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_URI') + .replace_with(lambda _: mock_balanced_customer) + .once() + ) + + processor = self.make_one(customer_cls=BalancedCustomer) + processor.prepare_customer(customer, '/v1/cards/my_card') + + def test_prepare_customer_with_bank_account(self): + import balanced + + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_URI', + ) + customer = self.customer_model.get(self.customer_guid) + + # make sure API key is set correctly + ( + flexmock(balanced) + .should_receive('configure') + .with_args('my_secret_key') + .once() + ) + + # mock balanced.Customer instance + mock_balanced_customer = ( + flexmock() + .should_receive('add_bank_account') + .with_args('/v1/bank_accounts/my_account') + .once() + .mock() + ) + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, uri): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_URI') + .replace_with(lambda _: mock_balanced_customer) + .once() + ) + + processor = self.make_one(customer_cls=BalancedCustomer) + processor.prepare_customer(customer, '/v1/bank_accounts/my_account') + + def test_prepare_customer_with_none_payment_uri(self): + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_URI', + ) + customer = self.customer_model.get(self.customer_guid) + + # mock balanced.Customer instance + mock_balanced_customer = ( + flexmock() + .should_receive('add_bank_account') + .never() + .mock() + ) + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, uri): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_URI') + .replace_with(lambda _: mock_balanced_customer) + .never() + ) + + processor = self.make_one(customer_cls=BalancedCustomer) + processor.prepare_customer(customer, None) + + def test_prepare_customer_with_bad_payment_uri(self): + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_URI', + ) + customer = self.customer_model.get(self.customer_guid) + + # mock balanced.Customer instance + mock_balanced_customer = flexmock() + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, uri): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_URI') + .replace_with(lambda _: mock_balanced_customer) + .once() + ) + + processor = self.make_one(customer_cls=BalancedCustomer) + with self.assertRaises(ValueError): + processor.prepare_customer(customer, '/v1/bitcoin/12345') + + def _test_operation( + self, + cls_name, + processor_method_name, + api_method_name, + extra_api_kwargs, + ): + import balanced + + tx_model = self.transaction_model + with db_transaction.manager: + guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + transaction = tx_model.get(guid) + self.customer_model.update( + guid=transaction.subscription.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_URI', + ) + transaction = tx_model.get(guid) + + # make sure API key is set correctly + ( + flexmock(balanced) + .should_receive('configure') + .with_args('my_secret_key') + .once() + ) + + # mock result page object of balanced.RESOURCE.query.filter(...) + + def mock_one(): + raise balanced.exc.NoResultFound + + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(mock_one) + .once() + .mock() + ) + + # mock balanced.RESOURCE.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.RESOURCE class + class Resource(object): + pass + Resource.query = mock_query + + # mock balanced.RESOURCE instance + mock_resource = flexmock(uri='MOCK_BALANCED_RESOURCE_URI') + + # mock balanced.Customer instance + kwargs = dict( + amount=int(transaction.amount * 100), + meta={'billy.transaction_guid': transaction.guid}, + description=( + 'Generated by Billy from subscription {}, scheduled_at={}' + .format(transaction.subscription.guid, transaction.scheduled_at) + ) + ) + kwargs.update(extra_api_kwargs) + mock_balanced_customer = ( + flexmock() + .should_receive(api_method_name) + .with_args(**kwargs) + .replace_with(lambda **kw: mock_resource) + .once() + .mock() + ) + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, uri): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_URI') + .replace_with(lambda _: mock_balanced_customer) + .once() + ) + + processor = self.make_one( + customer_cls=BalancedCustomer, + **{cls_name: Resource} + ) + method = getattr(processor, processor_method_name) + balanced_tx_id = method(transaction) + self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_RESOURCE_URI') + + def _test_operation_with_created_record( + self, + cls_name, + processor_method_name, + ): + tx_model = self.transaction_model + with db_transaction.manager: + guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + transaction = tx_model.get(guid) + transaction = tx_model.get(guid) + + # mock balanced.RESOURCE instance + mock_resource = flexmock(uri='MOCK_BALANCED_RESOURCE_URI') + + # mock result page object of balanced.RESOURCE.query.filter(...) + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(lambda: mock_resource) + .once() + .mock() + ) + + # mock balanced.RESOURCE.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.RESOURCE class + class Resource(object): + pass + Resource.query = mock_query + + processor = self.make_one(**{cls_name: Resource}) + method = getattr(processor, processor_method_name) + balanced_res_uri = method(transaction) + self.assertEqual(balanced_res_uri, 'MOCK_BALANCED_RESOURCE_URI') + + def test_charge(self): + self._test_operation( + cls_name='debit_cls', + processor_method_name='charge', + api_method_name='debit', + extra_api_kwargs=dict(source_uri='/v1/credit_card/tester'), + ) + + def test_charge_with_created_record(self): + self._test_operation_with_created_record( + cls_name='debit_cls', + processor_method_name='charge', + ) + + def test_payout(self): + self._test_operation( + cls_name='credit_cls', + processor_method_name='payout', + api_method_name='credit', + extra_api_kwargs=dict(destination_uri='/v1/credit_card/tester'), + ) + + def test_payout_with_created_record(self): + self._test_operation_with_created_record( + cls_name='credit_cls', + processor_method_name='payout', + ) + + def test_refund(self): + import balanced + + tx_model = self.transaction_model + with db_transaction.manager: + charge_guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + charge_transaction = tx_model.get(charge_guid) + charge_transaction.status = tx_model.STATUS_DONE + charge_transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(charge_transaction) + self.session.flush() + + refund_guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_REFUND, + refund_to_guid=charge_guid, + amount=56, + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = tx_model.get(refund_guid) + + # make sure API key is set correctly + ( + flexmock(balanced) + .should_receive('configure') + .with_args('my_secret_key') + .once() + ) + + # mock result page object of balanced.Refund.query.filter(...) + + def mock_one(): + raise balanced.exc.NoResultFound + + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(mock_one) + .once() + .mock() + ) + + # mock balanced.Refund.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.Refund class + class Refund(object): + pass + Refund.query = mock_query + + # mock balanced.Refund instance + mock_refund = flexmock(uri='MOCK_BALANCED_REFUND_URI') + + # mock balanced.Debit instance + kwargs = dict( + amount=int(transaction.amount * 100), + meta={'billy.transaction_guid': transaction.guid}, + description=( + 'Generated by Billy from subscription {}, scheduled_at={}' + .format(transaction.subscription.guid, transaction.scheduled_at) + ) + ) + mock_balanced_debit = ( + flexmock() + .should_receive('refund') + .with_args(**kwargs) + .replace_with(lambda **kw: mock_refund) + .once() + .mock() + ) + + # mock balanced.Debit class + class BalancedDebit(object): + def find(self, uri): + pass + ( + flexmock(BalancedDebit) + .should_receive('find') + .with_args('MOCK_BALANCED_DEBIT_URI') + .replace_with(lambda _: mock_balanced_debit) + .once() + ) + + processor = self.make_one( + refund_cls=Refund, + debit_cls=BalancedDebit, + ) + refund_uri = processor.refund(transaction) + self.assertEqual(refund_uri, 'MOCK_BALANCED_REFUND_URI') + + def test_refund_with_created_record(self): + tx_model = self.transaction_model + with db_transaction.manager: + charge_guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + charge_transaction = tx_model.get(charge_guid) + charge_transaction.status = tx_model.STATUS_DONE + charge_transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(charge_transaction) + self.session.flush() + + refund_guid = tx_model.create( + subscription_guid=self.subscription_guid, + transaction_type=tx_model.TYPE_REFUND, + refund_to_guid=charge_guid, + amount=56, + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = tx_model.get(refund_guid) + + # mock balanced.Refund instance + mock_refund = flexmock(uri='MOCK_BALANCED_REFUND_URI') + + # mock result page object of balanced.Refund.query.filter(...) + + def mock_one(): + return mock_refund + + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(mock_one) + .once() + .mock() + ) + + # mock balanced.Refund.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.Refund class + class Refund(object): + pass + Refund.query = mock_query + + processor = self.make_one(refund_cls=Refund) + refund_uri = processor.refund(transaction) + self.assertEqual(refund_uri, 'MOCK_BALANCED_REFUND_URI') diff --git a/billy/tests/unit/test_models/test_schedule.py b/billy/tests/unit/test_models/test_schedule.py new file mode 100644 index 0000000..d0b1565 --- /dev/null +++ b/billy/tests/unit/test_models/test_schedule.py @@ -0,0 +1,274 @@ +from __future__ import unicode_literals +import unittest +import datetime + +from freezegun import freeze_time + + +@freeze_time('2013-08-16') +class TestSchedule(unittest.TestCase): + + def assert_schedule(self, started_at, frequency, interval, length, expected): + from billy.models.schedule import next_transaction_datetime + result = [] + for period in range(length): + dt = next_transaction_datetime( + started_at=started_at, + frequency=frequency, + period=period, + interval=interval, + ) + result.append(dt) + self.assertEqual(result, expected) + + def test_invalid_freq_type(self): + from billy.models.schedule import next_transaction_datetime + with self.assertRaises(ValueError): + next_transaction_datetime( + started_at=datetime.datetime.utcnow(), + frequency=999, + period=0, + interval=1, + ) + + def test_invalid_interval(self): + from billy.models.plan import PlanModel + from billy.models.schedule import next_transaction_datetime + with self.assertRaises(ValueError): + next_transaction_datetime( + started_at=datetime.datetime.utcnow(), + frequency=PlanModel.FREQ_DAILY, + period=0, + interval=0, + ) + with self.assertRaises(ValueError): + next_transaction_datetime( + started_at=datetime.datetime.utcnow(), + frequency=PlanModel.FREQ_DAILY, + period=0, + interval=-1, + ) + + def test_daily_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-07-28'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_DAILY, + interval=1, + length=10, + expected=[ + datetime.datetime(2013, 7, 28), + datetime.datetime(2013, 7, 29), + datetime.datetime(2013, 7, 30), + datetime.datetime(2013, 7, 31), + datetime.datetime(2013, 8, 1), + datetime.datetime(2013, 8, 2), + datetime.datetime(2013, 8, 3), + datetime.datetime(2013, 8, 4), + datetime.datetime(2013, 8, 5), + datetime.datetime(2013, 8, 6), + ] + ) + + def test_daily_schedule_with_interval(self): + from billy.models.plan import PlanModel + with freeze_time('2013-07-28'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_DAILY, + interval=3, + length=4, + expected=[ + datetime.datetime(2013, 7, 28), + datetime.datetime(2013, 7, 31), + datetime.datetime(2013, 8, 3), + datetime.datetime(2013, 8, 6), + ] + ) + + def test_daily_schedule_with_end_of_month(self): + from billy.models.plan import PlanModel + from billy.models.schedule import next_transaction_datetime + + def assert_next_day(now_dt, expected): + with freeze_time(now_dt): + now = datetime.datetime.utcnow() + next_dt = next_transaction_datetime( + started_at=now, + frequency=PlanModel.FREQ_DAILY, + period=1, + ) + self.assertEqual(next_dt, expected) + + assert_next_day('2013-01-31', datetime.datetime(2013, 2, 1)) + assert_next_day('2013-02-28', datetime.datetime(2013, 3, 1)) + assert_next_day('2013-03-31', datetime.datetime(2013, 4, 1)) + assert_next_day('2013-04-30', datetime.datetime(2013, 5, 1)) + assert_next_day('2013-05-31', datetime.datetime(2013, 6, 1)) + assert_next_day('2013-06-30', datetime.datetime(2013, 7, 1)) + assert_next_day('2013-07-31', datetime.datetime(2013, 8, 1)) + assert_next_day('2013-08-31', datetime.datetime(2013, 9, 1)) + assert_next_day('2013-09-30', datetime.datetime(2013, 10, 1)) + assert_next_day('2013-10-31', datetime.datetime(2013, 11, 1)) + assert_next_day('2013-11-30', datetime.datetime(2013, 12, 1)) + assert_next_day('2013-12-31', datetime.datetime(2014, 1, 1)) + + def test_weekly_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_WEEKLY, + interval=1, + length=5, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2013, 8, 25), + datetime.datetime(2013, 9, 1), + datetime.datetime(2013, 9, 8), + datetime.datetime(2013, 9, 15), + ] + ) + + def test_weekly_schedule_with_interval(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_WEEKLY, + interval=2, + length=3, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2013, 9, 1), + datetime.datetime(2013, 9, 15), + ] + ) + + def test_monthly_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_MONTHLY, + interval=1, + length=6, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2013, 9, 18), + datetime.datetime(2013, 10, 18), + datetime.datetime(2013, 11, 18), + datetime.datetime(2013, 12, 18), + datetime.datetime(2014, 1, 18), + ] + ) + + def test_monthly_schedule_with_interval(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_MONTHLY, + interval=6, + length=4, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2014, 2, 18), + datetime.datetime(2014, 8, 18), + datetime.datetime(2015, 2, 18), + ] + ) + + def test_monthly_schedule_with_end_of_month(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-31'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_MONTHLY, + interval=1, + length=7, + expected=[ + datetime.datetime(2013, 8, 31), + datetime.datetime(2013, 9, 30), + datetime.datetime(2013, 10, 31), + datetime.datetime(2013, 11, 30), + datetime.datetime(2013, 12, 31), + datetime.datetime(2014, 1, 31), + datetime.datetime(2014, 2, 28), + ] + ) + + with freeze_time('2013-11-30'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_MONTHLY, + interval=1, + length=6, + expected=[ + datetime.datetime(2013, 11, 30), + datetime.datetime(2013, 12, 30), + datetime.datetime(2014, 1, 30), + datetime.datetime(2014, 2, 28), + datetime.datetime(2014, 3, 30), + datetime.datetime(2014, 4, 30), + ] + ) + + def test_yearly_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_YEARLY, + interval=1, + length=5, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2014, 8, 18), + datetime.datetime(2015, 8, 18), + datetime.datetime(2016, 8, 18), + datetime.datetime(2017, 8, 18), + ]) + + def test_yearly_schedule_with_interval(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_YEARLY, + interval=2, + length=3, + expected=[ + datetime.datetime(2013, 8, 18), + datetime.datetime(2015, 8, 18), + datetime.datetime(2017, 8, 18), + ]) + + def test_yearly_schedule_with_leap_year(self): + from billy.models.plan import PlanModel + with freeze_time('2012-02-29'): + now = datetime.datetime.utcnow() + self.assert_schedule( + started_at=now, + frequency=PlanModel.FREQ_YEARLY, + interval=1, + length=5, + expected=[ + datetime.datetime(2012, 2, 29), + datetime.datetime(2013, 2, 28), + datetime.datetime(2014, 2, 28), + datetime.datetime(2015, 2, 28), + datetime.datetime(2016, 2, 29), + ] + ) diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py new file mode 100644 index 0000000..a6f6365 --- /dev/null +++ b/billy/tests/unit/test_models/test_subscription.py @@ -0,0 +1,758 @@ +from __future__ import unicode_literals +import datetime +import decimal + +import transaction as db_transaction +from freezegun import freeze_time + +from billy.tests.unit.helper import ModelTestCase + + +@freeze_time('2013-08-16') +class TestSubscriptionModel(ModelTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + super(TestSubscriptionModel, self).setUp() + # build the basic scenario for plan model + self.company_model = CompanyModel(self.session) + self.customer_model = CustomerModel(self.session) + self.plan_model = PlanModel(self.session) + with db_transaction.manager: + self.company_guid = self.company_model.create('my_secret_key') + self.daily_plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_DAILY, + ) + self.weekly_plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_WEEKLY, + ) + self.monthly_plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + self.customer_tom_guid = self.customer_model.create( + company_guid=self.company_guid, + ) + + def make_one(self, *args, **kwargs): + from billy.models.subscription import SubscriptionModel + return SubscriptionModel(*args, **kwargs) + + def test_get_subscription(self): + model = self.make_one(self.session) + + subscription = model.get('SU_NON_EXIST') + self.assertEqual(subscription, None) + + with self.assertRaises(KeyError): + model.get('SU_NON_EXIST', raise_error=True) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + subscription = model.get(guid, raise_error=True) + self.assertEqual(subscription.guid, guid) + + def test_create(self): + model = self.make_one(self.session) + amount = decimal.Decimal('99.99') + external_id = '5566_GOOD_BROTHERS' + customer_guid = self.customer_tom_guid + plan_guid = self.monthly_plan_guid + payment_uri = '/v1/credit_cards/id' + + with db_transaction.manager: + guid = model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + external_id=external_id, + payment_uri=payment_uri, + ) + + now = datetime.datetime.utcnow() + + subscription = model.get(guid) + self.assertEqual(subscription.guid, guid) + self.assert_(subscription.guid.startswith('SU')) + self.assertEqual(subscription.customer_guid, customer_guid) + self.assertEqual(subscription.plan_guid, plan_guid) + self.assertEqual(subscription.amount, amount) + self.assertEqual(subscription.external_id, external_id) + self.assertEqual(subscription.payment_uri, payment_uri) + self.assertEqual(subscription.period, 0) + self.assertEqual(subscription.canceled, False) + self.assertEqual(subscription.canceled_at, None) + self.assertEqual(subscription.started_at, now) + self.assertEqual(subscription.next_transaction_at, now) + self.assertEqual(subscription.created_at, now) + self.assertEqual(subscription.updated_at, now) + + def test_create_different_created_updated_time(self): + from billy.models import tables + model = self.make_one(self.session) + + results = [ + datetime.datetime(2013, 8, 16, 1), + datetime.datetime(2013, 8, 16, 2), + ] + + def mock_utcnow(): + return results.pop(0) + + tables.set_now_func(mock_utcnow) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + subscription = model.get(guid) + self.assertEqual(subscription.created_at, subscription.updated_at) + + def test_create_with_started_at(self): + model = self.make_one(self.session) + customer_guid = self.customer_tom_guid + plan_guid = self.monthly_plan_guid + started_at = datetime.datetime.utcnow() + datetime.timedelta(days=1) + + with db_transaction.manager: + guid = model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + started_at=started_at + ) + + subscription = model.get(guid) + self.assertEqual(subscription.guid, guid) + self.assertEqual(subscription.started_at, started_at) + + def test_create_with_past_started_at(self): + model = self.make_one(self.session) + started_at = datetime.datetime.utcnow() - datetime.timedelta(days=1) + with self.assertRaises(ValueError): + model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + started_at=started_at + ) + + def test_create_with_bad_amount(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + amount=-0.1, + ) + with self.assertRaises(ValueError): + model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + amount=0, + ) + + def test_update(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + external_id='old external id' + ) + + subscription = model.get(guid) + external_id = 'new external id' + + with db_transaction.manager: + model.update( + guid=guid, + external_id=external_id, + ) + + subscription = model.get(guid) + self.assertEqual(subscription.external_id, external_id) + + def test_update_updated_at(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + subscription = model.get(guid) + created_at = subscription.created_at + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with db_transaction.manager: + model.update(guid=guid) + updated_at = datetime.datetime.utcnow() + + subscription = model.get(guid) + self.assertEqual(subscription.canceled_at, None) + self.assertEqual(subscription.updated_at, updated_at) + self.assertEqual(subscription.created_at, created_at) + + # advanced the current date time even more + with freeze_time('2013-08-16 08:35:40'): + # this should update the updated_at field only + with db_transaction.manager: + model.update(guid) + updated_at = datetime.datetime.utcnow() + + subscription = model.get(guid) + self.assertEqual(subscription.canceled_at, None) + self.assertEqual(subscription.updated_at, updated_at) + self.assertEqual(subscription.created_at, created_at) + + def test_update_with_wrong_args(self): + model = self.make_one(self.session) + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update(guid, wrong_arg=True, neme='john') + + def test_subscription_cancel(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.cancel(guid) + + now = datetime.datetime.utcnow() + + subscription = model.get(guid) + self.assertEqual(subscription.canceled, True) + self.assertEqual(subscription.canceled_at, now) + + def test_subscription_cancel_not_done_transactions(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + # okay, 08-16, 09-16, 10-16, 11-16, so we should have 4 new transactions + # and we assume the transactions status as shown as following: + # + # [DONE, RETRYING, INIT, FAILED] + # + # when we cancel the subscription, the status should be + # + # [DONE, CANCELED, CANCELED, FAILED] + # + init_status = [ + tx_model.STATUS_DONE, + tx_model.STATUS_RETRYING, + tx_model.STATUS_INIT, + tx_model.STATUS_FAILED, + ] + with freeze_time('2013-11-16'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + for tx_guid, status in zip(tx_guids, init_status): + transaction = tx_model.get(tx_guid) + transaction.status = status + self.session.add(transaction) + self.session.add(transaction) + with db_transaction.manager: + model.cancel(guid) + + transactions = [tx_model.get(tx_guid) for tx_guid in tx_guids] + status_list = [tx.status for tx in transactions] + self.assertEqual(status_list, [ + tx_model.STATUS_DONE, + tx_model.STATUS_CANCELED, + tx_model.STATUS_CANCELED, + tx_model.STATUS_FAILED, + ]) + + def test_subscription_cancel_with_prorated_refund(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with freeze_time('2013-06-01'): + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions() + transaction = tx_model.get(tx_guids[0]) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(transaction) + + # it is a monthly plan, there is 30 days in June, and only + # 6 days are elapsed, so 6 / 30 days, the rate should be 1 - 0.2 = 0.8 + # and we have 10 as the amount, we should return 8 to customer + with freeze_time('2013-06-07'): + with db_transaction.manager: + refund_guid = model.cancel(guid, prorated_refund=True) + + transaction = tx_model.get(refund_guid) + self.assertEqual(transaction.refund_to_guid, tx_guids[0]) + self.assertEqual(transaction.subscription_guid, guid) + self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) + self.assertEqual(transaction.amount, decimal.Decimal('8')) + + def test_subscription_cancel_with_wrong_arguments(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions() + transaction = tx_model.get(tx_guids[0]) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(transaction) + + # we should not allow both prorated_refund and refund_amount to + # be set + with self.assertRaises(ValueError): + model.cancel(guid, prorated_refund=True, refund_amount=10) + # we should not allow refunding amount that grather than original + # subscription amount + with self.assertRaises(ValueError): + model.cancel(guid, refund_amount=decimal.Decimal('10.01')) + + def test_subscription_cancel_with_refund_amount(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions() + transaction = tx_model.get(tx_guids[0]) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(transaction) + + # let's cancel and refund the latest transaction with amount 5.66 + with db_transaction.manager: + refund_guid = model.cancel(guid, refund_amount=5.66) + + transaction = tx_model.get(refund_guid) + self.assertEqual(transaction.refund_to_guid, tx_guids[0]) + self.assertEqual(transaction.subscription_guid, guid) + self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) + self.assertEqual(transaction.amount, decimal.Decimal('5.66')) + + def test_subscription_cancel_with_prorated_refund_and_amount_overwrite(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with freeze_time('2013-06-01'): + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + amount=100, + ) + tx_guids = model.yield_transactions() + transaction = tx_model.get(tx_guids[0]) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(transaction) + + # it is a monthly plan, there is 30 days in June, and only + # 6 days are elapsed, so 6 / 30 days, the rate should be 1 - 0.2 = 0.8 + # and we have 100 as the amount, we should return 80 to customer + with freeze_time('2013-06-07'): + with db_transaction.manager: + refund_guid = model.cancel(guid, prorated_refund=True) + + transaction = tx_model.get(refund_guid) + # the orignal price is 10, then overwritten by subscription as 100 + # and we refund half, then the refund amount should be 50 + self.assertEqual(transaction.amount, decimal.Decimal('80')) + + def test_subscription_cancel_with_prorated_refund_rounding(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with freeze_time('2013-06-01'): + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions() + transaction = tx_model.get(tx_guids[0]) + transaction.status = tx_model.STATUS_DONE + transaction.external_id = 'MOCK_BALANCED_DEBIT_URI' + self.session.add(transaction) + + # 17 / 30 days, the rate should be 1 - 0.56666..., which is + # 0.43333... + with freeze_time('2013-06-18'): + with db_transaction.manager: + refund_guid = model.cancel(guid, prorated_refund=True) + + transaction = tx_model.get(refund_guid) + self.assertEqual(transaction.amount, decimal.Decimal('4.33')) + + def test_subscription_cancel_with_zero_refund(self): + model = self.make_one(self.session) + + with freeze_time('2013-06-01'): + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.yield_transactions() + # the subscription period is finished, nothing to refund + with freeze_time('2013-07-01'): + with db_transaction.manager: + refund_guid = model.cancel(guid, prorated_refund=True) + + self.assertEqual(refund_guid, None) + subscription = model.get(guid) + transactions = subscription.transactions + self.assertEqual(len(transactions), 1) + + def test_subscription_cancel_twice(self): + from billy.models.subscription import SubscriptionCanceledError + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.cancel(guid) + + with self.assertRaises(SubscriptionCanceledError): + model.cancel(guid) + + def test_yield_transactions(self): + from billy.models.transaction import TransactionModel + + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + now = datetime.datetime.utcnow() + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions() + + self.assertEqual(len(tx_guids), 1) + + subscription = model.get(guid) + transactions = subscription.transactions + self.assertEqual(len(transactions), 1) + + transaction = transactions[0] + self.assertEqual(transaction.guid, tx_guids[0]) + self.assertEqual(transaction.subscription_guid, guid) + self.assertEqual(transaction.amount, subscription.plan.amount) + self.assertEqual(transaction.transaction_type, + TransactionModel.TYPE_CHARGE) + self.assertEqual(transaction.scheduled_at, now) + self.assertEqual(transaction.created_at, now) + self.assertEqual(transaction.updated_at, now) + self.assertEqual(transaction.status, TransactionModel.STATUS_INIT) + + # we should not yield new transaction as the datetime is the same + with db_transaction.manager: + tx_guids = model.yield_transactions() + self.assertFalse(tx_guids) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 1) + + # should not yield new transaction as 09-16 is the date + with freeze_time('2013-09-15'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + self.assertFalse(tx_guids) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 1) + + # okay, should yield new transaction now + with freeze_time('2013-09-16'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + scheduled_at = datetime.datetime.utcnow() + self.assertEqual(len(tx_guids), 1) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 2) + + transaction = tx_model.get(tx_guids[0]) + self.assertEqual(transaction.subscription_guid, guid) + self.assertEqual(transaction.amount, subscription.plan.amount) + self.assertEqual(transaction.transaction_type, + TransactionModel.TYPE_CHARGE) + self.assertEqual(transaction.scheduled_at, scheduled_at) + self.assertEqual(transaction.created_at, scheduled_at) + self.assertEqual(transaction.updated_at, scheduled_at) + self.assertEqual(transaction.status, TransactionModel.STATUS_INIT) + + def test_yield_transactions_for_specific_subscriptions(self): + from billy.models.transaction import TransactionModel + + model = self.make_one(self.session) + tx_model = TransactionModel(self.session) + + with db_transaction.manager: + guid1 = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + guid2 = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + tx_guids = model.yield_transactions([guid1, guid2]) + + self.assertEqual(len(tx_guids), 2) + subscription_guids = [tx_model.get(tx_guid).subscription_guid + for tx_guid in tx_guids] + self.assertEqual(set(subscription_guids), set([guid1, guid2])) + + def test_yield_transactions_with_multiple_period(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + # okay, 08-16, 09-16, 10-16, so we should have 3 new transactions + with freeze_time('2013-10-16'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertEqual(len(set(tx_guids)), 3) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 3) + + sub_tx_guids = [tx.guid for tx in subscription.transactions] + self.assertEqual(set(tx_guids), set(sub_tx_guids)) + + from billy.models import tables + q = self.session.query(tables.Transaction).filter_by(subscription_guid=guid) + tx_dates = [tx.scheduled_at for tx in subscription.transactions] + self.assertEqual(set(tx_dates), set([ + datetime.datetime(2013, 8, 16), + datetime.datetime(2013, 9, 16), + datetime.datetime(2013, 10, 16), + ])) + + def test_yield_transactions_with_amount_overwrite(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + amount=55.66, + ) + + # okay, 08-16, 09-16, 10-16, so we should have 3 new transactions + with freeze_time('2013-10-16'): + with db_transaction.manager: + model.yield_transactions() + + subscription = model.get(guid) + amounts = [tx.amount for tx in subscription.transactions] + self.assertEqual(amounts, [ + decimal.Decimal('55.66'), + decimal.Decimal('55.66'), + decimal.Decimal('55.66'), + ]) + + def test_yield_transactions_with_multiple_interval(self): + model = self.make_one(self.session) + + with db_transaction.manager: + plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_PAYOUT, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + interval=2, + ) + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=plan_guid, + ) + + # okay, 08-16, 10-16, so we should have 2 new transactions + with freeze_time('2013-10-16'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertEqual(len(set(tx_guids)), 2) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 2) + + def test_yield_transactions_with_payout(self): + from billy.models.transaction import TransactionModel + model = self.make_one(self.session) + + with db_transaction.manager: + plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_PAYOUT, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=plan_guid, + ) + model.yield_transactions() + + subscription = model.get(guid) + transaction = subscription.transactions[0] + self.assertEqual(transaction.transaction_type, + TransactionModel.TYPE_PAYOUT) + + def test_yield_transactions_with_started_at(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + started_at=datetime.datetime(2013, 9, 1), + ) + + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertFalse(tx_guids) + subscription = model.get(guid) + self.assertFalse(subscription.transactions) + + # + with freeze_time('2013-09-01'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertEqual(len(set(tx_guids)), 1) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 1) + + transaction = subscription.transactions[0] + self.assertEqual(transaction.scheduled_at, + datetime.datetime(2013, 9, 1)) + + def test_yield_transactions_with_wrong_type(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + subscription = model.get(guid) + subscription.plan.plan_type = 999 + self.session.add(subscription.plan) + + with self.assertRaises(ValueError): + model.yield_transactions() + + def test_yield_transactions_with_canceled_subscription(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + started_at=datetime.datetime(2013, 9, 1), + ) + model.cancel(guid) + + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertFalse(tx_guids) + subscription = model.get(guid) + self.assertFalse(subscription.transactions) + + def test_yield_transactions_with_canceled_in_middle(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + # 08-16, 09-16, 10-16 transactions should be yielded + with freeze_time('2013-10-16'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertEqual(len(set(tx_guids)), 3) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 3) + + # okay, cancel this, there should be no more new transactions + with db_transaction.manager: + model.cancel(guid) + + with freeze_time('2020-12-31'): + with db_transaction.manager: + tx_guids = model.yield_transactions() + + self.assertFalse(tx_guids) + subscription = model.get(guid) + self.assertEqual(len(subscription.transactions), 3) diff --git a/billy/tests/unit/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py new file mode 100644 index 0000000..af86466 --- /dev/null +++ b/billy/tests/unit/test_models/test_transaction.py @@ -0,0 +1,785 @@ +from __future__ import unicode_literals +import datetime +import decimal + +import transaction as db_transaction +from flexmock import flexmock +from freezegun import freeze_time + +from billy.tests.unit.helper import ModelTestCase + + +@freeze_time('2013-08-16') +class TestTransactionModel(ModelTestCase): + + def setUp(self): + from billy.models.company import CompanyModel + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + from billy.models.subscription import SubscriptionModel + super(TestTransactionModel, self).setUp() + # build the basic scenario for transaction model + self.company_model = CompanyModel(self.session) + self.customer_model = CustomerModel(self.session) + self.plan_model = PlanModel(self.session) + self.subscription_model = SubscriptionModel(self.session) + with db_transaction.manager: + self.company_guid = self.company_model.create('my_secret_key') + self.plan_guid = self.plan_model.create( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + self.customer_guid = self.customer_model.create( + company_guid=self.company_guid, + ) + self.subscription_guid = self.subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + payment_uri='/v1/cards/tester', + ) + + def make_one(self, *args, **kwargs): + from billy.models.transaction import TransactionModel + return TransactionModel(*args, **kwargs) + + def test_get_transaction(self): + model = self.make_one(self.session) + + transaction = model.get('TX_NON_EXIST') + self.assertEqual(transaction, None) + + with self.assertRaises(KeyError): + model.get('TX_NON_EXIST', raise_error=True) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = model.get(guid, raise_error=True) + self.assertEqual(transaction.guid, guid) + + def test_list_by_company_guid(self): + model = self.make_one(self.session) + # Following code basicly crerates another company with records + # like this: + # + # + Company (other) + # + Customer1 (shared by two subscriptions) + # + Plan1 + # + Subscription1 + # + Transaction1 + # + Plan2 + # + Subscription2 + # + Transaction2 + # + with db_transaction.manager: + other_company_guid = self.company_model.create('my_secret_key') + other_plan_guid1 = self.plan_model.create( + company_guid=other_company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + other_plan_guid2 = self.plan_model.create( + company_guid=other_company_guid, + plan_type=self.plan_model.TYPE_CHARGE, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + other_customer_guid = self.customer_model.create( + company_guid=other_company_guid, + ) + other_subscription_guid1 = self.subscription_model.create( + customer_guid=other_customer_guid, + plan_guid=other_plan_guid1, + payment_uri='/v1/cards/tester', + ) + other_subscription_guid2 = self.subscription_model.create( + customer_guid=other_customer_guid, + plan_guid=other_plan_guid2, + payment_uri='/v1/cards/tester', + ) + with db_transaction.manager: + other_guid1 = model.create( + subscription_guid=other_subscription_guid1, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + other_guid2 = model.create( + subscription_guid=other_subscription_guid2, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + # Following code basicly crerates our records under default company + # like this: + # + # + Company (default) + # + Customer1 + # + Plan1 + # + Subscription1 + # + Transaction1 + # + Transaction2 + # + Transaction3 + # + with db_transaction.manager: + guid1 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + guid2 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + guid3 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + result_guids = [tx.guid for tx in + model.list_by_company_guid(self.company_guid)] + self.assertEqual(set(result_guids), set([guid1, guid2, guid3])) + result_guids = [tx.guid for tx in + model.list_by_company_guid(other_company_guid)] + self.assertEqual(set(result_guids), set([other_guid1, other_guid2])) + + def test_list_by_company_guid_with_offset_limit(self): + model = self.make_one(self.session) + guids = [] + with db_transaction.manager: + for i in range(10): + with freeze_time('2013-08-16 00:00:{:02}'.format(i)): + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10 * i, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + guids.append(guid) + + def assert_list(offset, limit, expected): + result = model.list_by_company_guid( + self.company_guid, + offset=offset, + limit=limit, + ) + result_guids = [tx.guid for tx in result] + self.assertEqual(set(result_guids), set(expected)) + assert_list(0, 0, []) + assert_list(10, 10, []) + assert_list(0, 10, guids) + assert_list(0, 1, guids[:1]) + assert_list(1, 1, guids[1:2]) + assert_list(5, 1000, guids[5:]) + assert_list(5, 10, guids[5:]) + + def test_create(self): + model = self.make_one(self.session) + + subscription_guid = self.subscription_guid + transaction_type = model.TYPE_CHARGE + amount = 100 + payment_uri = '/v1/cards/tester' + now = datetime.datetime.utcnow() + scheduled_at = now + datetime.timedelta(days=1) + + with db_transaction.manager: + guid = model.create( + subscription_guid=subscription_guid, + transaction_type=transaction_type, + amount=amount, + payment_uri=payment_uri, + scheduled_at=scheduled_at, + ) + + transaction = model.get(guid) + self.assertEqual(transaction.guid, guid) + self.assert_(transaction.guid.startswith('TX')) + self.assertEqual(transaction.subscription_guid, subscription_guid) + self.assertEqual(transaction.transaction_type, transaction_type) + self.assertEqual(transaction.amount, amount) + self.assertEqual(transaction.payment_uri, payment_uri) + self.assertEqual(transaction.status, model.STATUS_INIT) + self.assertEqual(transaction.failure_count, 0) + self.assertEqual(transaction.error_message, None) + self.assertEqual(transaction.scheduled_at, scheduled_at) + self.assertEqual(transaction.created_at, now) + self.assertEqual(transaction.updated_at, now) + + def test_create_different_created_updated_time(self): + from billy.models import tables + model = self.make_one(self.session) + + results = [ + datetime.datetime(2013, 8, 16, 1), + datetime.datetime(2013, 8, 16, 2), + ] + + def mock_utcnow(): + return results.pop(0) + + tables.set_now_func(mock_utcnow) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = model.get(guid) + self.assertEqual(transaction.created_at, transaction.updated_at) + + def test_create_refund(self): + model = self.make_one(self.session) + + now = datetime.datetime.utcnow() + + with db_transaction.manager: + tx_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=now, + ) + + with db_transaction.manager: + refund_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid=tx_guid, + amount=50, + scheduled_at=now, + ) + + refund_transaction = model.get(refund_guid) + self.assertEqual(refund_transaction.refund_to_guid, tx_guid) + self.assertEqual(refund_transaction.refund_to.guid, tx_guid) + self.assertEqual(refund_transaction.refund_to.refund_from.guid, + refund_guid) + self.assertEqual(refund_transaction.transaction_type, model.TYPE_REFUND) + self.assertEqual(refund_transaction.amount, decimal.Decimal(50)) + + def test_create_refund_with_non_exist_target(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + with self.assertRaises(KeyError): + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid='TX_NON_EXIST', + amount=50, + scheduled_at=now, + ) + + def test_create_refund_with_wrong_transaction_type(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + with self.assertRaises(ValueError): + tx_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=now, + ) + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_PAYOUT, + refund_to_guid=tx_guid, + amount=50, + scheduled_at=now, + ) + + def test_create_refund_with_payment_uri(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + with self.assertRaises(ValueError): + tx_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=now, + ) + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid=tx_guid, + amount=50, + scheduled_at=now, + payment_uri='/v1/cards/tester', + ) + + def test_create_refund_with_wrong_target(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + with db_transaction.manager: + tx_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=now, + ) + refund_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid=tx_guid, + amount=50, + scheduled_at=now, + ) + + with self.assertRaises(ValueError): + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid=refund_guid, + amount=50, + scheduled_at=now, + ) + + with db_transaction.manager: + tx_guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_PAYOUT, + amount=100, + payment_uri='/v1/cards/tester', + scheduled_at=now, + ) + + with self.assertRaises(ValueError): + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_REFUND, + refund_to_guid=refund_guid, + amount=50, + scheduled_at=now, + ) + + def test_create_with_wrong_type(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create( + subscription_guid=self.subscription_guid, + transaction_type=999, + amount=123, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + def test_update(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = model.get(guid) + status = model.STATUS_DONE + + with db_transaction.manager: + model.update( + guid=guid, + status=status, + ) + + transaction = model.get(guid) + self.assertEqual(transaction.status, status) + + def test_update_updated_at(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + transaction = model.get(guid) + created_at = transaction.created_at + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with db_transaction.manager: + model.update(guid=guid) + updated_at = datetime.datetime.utcnow() + + transaction = model.get(guid) + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.created_at, created_at) + + # advanced the current date time even more + with freeze_time('2013-08-16 08:35:40'): + # this should update the updated_at field only + with db_transaction.manager: + model.update(guid) + updated_at = datetime.datetime.utcnow() + + transaction = model.get(guid) + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.created_at, created_at) + + def test_update_with_wrong_args(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update( + guid=guid, + wrong_arg=True, + status=model.STATUS_INIT + ) + + def test_update_with_wrong_status(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + + with self.assertRaises(ValueError): + model.update( + guid=guid, + status=999, + ) + + def test_base_processor(self): + from billy.models.processors.base import PaymentProcessor + processor = PaymentProcessor() + with self.assertRaises(NotImplementedError): + processor.create_customer(None) + with self.assertRaises(NotImplementedError): + processor.prepare_customer(None) + with self.assertRaises(NotImplementedError): + processor.charge(None) + with self.assertRaises(NotImplementedError): + processor.payout(None) + + def test_process_one_charge(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + transaction = model.get(guid) + customer = transaction.subscription.customer + + mock_processor = flexmock() + ( + mock_processor + .should_receive('create_customer') + .with_args(customer) + .replace_with(lambda c: 'AC_MOCK') + .once() + ) + ( + mock_processor + .should_receive('prepare_customer') + .with_args(customer, payment_uri) + .replace_with(lambda c, payment_uri: None) + .once() + ) + ( + mock_processor + .should_receive('charge') + .with_args(transaction) + .replace_with(lambda t: 'TX_MOCK') + .once() + ) + + with freeze_time('2013-08-20'): + with db_transaction.manager: + model.process_one(mock_processor, transaction) + updated_at = datetime.datetime.utcnow() + + transaction = model.get(guid) + self.assertEqual(transaction.status, model.STATUS_DONE) + self.assertEqual(transaction.external_id, 'TX_MOCK') + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.scheduled_at, now) + self.assertEqual(transaction.created_at, now) + self.assertEqual(transaction.subscription.customer.external_id, + 'AC_MOCK') + + def test_process_one_payout(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_PAYOUT, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + transaction = model.get(guid) + customer = transaction.subscription.customer + + mock_processor = flexmock() + ( + mock_processor + .should_receive('create_customer') + .with_args(customer) + .replace_with(lambda c: 'AC_MOCK') + .once() + ) + ( + mock_processor + .should_receive('prepare_customer') + .with_args(customer, payment_uri) + .replace_with(lambda c, payment_uri: None) + .once() + ) + ( + mock_processor + .should_receive('payout') + .with_args(transaction) + .replace_with(lambda t: 'TX_MOCK') + .once() + ) + + with freeze_time('2013-08-20'): + with db_transaction.manager: + model.process_one(mock_processor, transaction) + updated_at = datetime.datetime.utcnow() + + transaction = model.get(guid) + self.assertEqual(transaction.status, model.STATUS_DONE) + self.assertEqual(transaction.external_id, 'TX_MOCK') + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.scheduled_at, now) + self.assertEqual(transaction.created_at, now) + self.assertEqual(transaction.subscription.customer.external_id, + 'AC_MOCK') + + def test_process_one_with_failure(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + transaction = model.get(guid) + customer = transaction.subscription.customer + + def mock_charge(transaction): + raise RuntimeError('Failed to charge') + + mock_processor = flexmock() + ( + mock_processor + .should_receive('create_customer') + .with_args(customer) + .replace_with(lambda c: 'AC_MOCK') + .once() + ) + ( + mock_processor + .should_receive('prepare_customer') + .with_args(customer, payment_uri) + .replace_with(lambda c, payment_uri: None) + .once() + ) + ( + mock_processor + .should_receive('charge') + .with_args(transaction) + .replace_with(mock_charge) + .once() + ) + + with db_transaction.manager: + model.process_one(mock_processor, transaction) + updated_at = datetime.datetime.utcnow() + + transaction = model.get(guid) + self.assertEqual(transaction.status, model.STATUS_RETRYING) + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.failure_count, 1) + self.assertEqual(transaction.error_message, 'Failed to charge') + self.assertEqual(transaction.subscription.customer.external_id, + 'AC_MOCK') + + def test_process_one_with_system_exit_and_keyboard_interrupt(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + transaction = model.get(guid) + + def mock_create_customer_system_exit(transaction): + raise SystemExit + + mock_processor = flexmock() + ( + mock_processor + .should_receive('create_customer') + .replace_with(mock_create_customer_system_exit) + ) + + with self.assertRaises(SystemExit): + model.process_one(mock_processor, transaction) + + def mock_create_customer_keyboard_interrupt(transaction): + raise KeyboardInterrupt + + mock_processor = flexmock() + ( + mock_processor + .should_receive('create_customer') + .replace_with(mock_create_customer_keyboard_interrupt) + ) + + with self.assertRaises(KeyboardInterrupt): + model.process_one(mock_processor, transaction) + + def test_process_one_with_already_done(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + transaction = model.get(guid) + transaction.status = model.STATUS_DONE + self.session.add(transaction) + + processor = flexmock() + transaction = model.get(guid) + with self.assertRaises(ValueError): + model.process_one(processor, transaction) + + def test_process_transactions(self): + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/cards/tester' + + with db_transaction.manager: + guid1 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + guid2 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + model.update(guid2, status=model.STATUS_RETRYING) + + guid3 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + + guid4 = model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + payment_uri=payment_uri, + scheduled_at=now, + ) + model.update(guid4, status=model.STATUS_DONE) + + processor = flexmock() + with db_transaction.manager: + tx_guids = model.process_transactions(processor) + + self.assertEqual(set(tx_guids), set([guid1, guid2, guid3])) diff --git a/billy/tests/unit/test_utils/__init__.py b/billy/tests/unit/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billy/tests/unit/test_utils/test_generic.py b/billy/tests/unit/test_utils/test_generic.py new file mode 100644 index 0000000..8a11e96 --- /dev/null +++ b/billy/tests/unit/test_utils/test_generic.py @@ -0,0 +1,51 @@ +from __future__ import unicode_literals +import unittest + + +class TestGenericUtils(unittest.TestCase): + + def test_make_b58encode(self): + from billy.utils.generic import b58encode + + def assert_encode(data, expected): + self.assertEqual(b58encode(data), expected) + + assert_encode('', '1') + assert_encode('\00', '1') + assert_encode('hello world', 'StV1DL6CwTryKyV') + + def test_make_guid(self): + from billy.utils.generic import make_guid + + # just make sure it is random + guids = [make_guid() for _ in range(100)] + self.assertEqual(len(set(guids)), 100) + + def test_make_api_key(self): + from billy.utils.generic import make_api_key + + # just make sure it is random + api_keys = [make_api_key() for _ in range(1000)] + self.assertEqual(len(set(api_keys)), 1000) + + def test_round_down_cent(self): + from decimal import Decimal + from billy.utils.generic import round_down_cent + + def assert_round_down(amount, expected): + self.assertEqual( + round_down_cent(Decimal(amount)), + Decimal(expected) + ) + + assert_round_down('0.0', '0.0') + assert_round_down('0.1', '0.1') + assert_round_down('0.11', '0.11') + assert_round_down('1.0', '1.0') + assert_round_down('1.12', '1.12') + assert_round_down('123.0', '123.0') + assert_round_down('0.123', '0.12') + assert_round_down('0.1234', '0.12') + assert_round_down('0.5566', '0.55') + assert_round_down('0.7788', '0.77') + assert_round_down('1.23456789', '1.23') diff --git a/billy/utils/__init__.py b/billy/utils/__init__.py index 2dd5d73..e69de29 100644 --- a/billy/utils/__init__.py +++ b/billy/utils/__init__.py @@ -1 +0,0 @@ -from intervals import Intervals diff --git a/billy/utils/fields.py b/billy/utils/fields.py deleted file mode 100644 index 421594b..0000000 --- a/billy/utils/fields.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Pulled from flask restful for local modificaitons. -""" -from decimal import Decimal as MyDecimal, ROUND_HALF_EVEN -import urlparse -from flask_restful import types, marshal -from flask import url_for - -__all__ = ["String", "FormattedString", "Url", "DateTime", "Float", - "Integer", "Arbitrary", "Nested", "List", "Raw"] - - -class MarshallingException(Exception): - """ - This is an encapsulating Exception in case of marshalling error. - """ - - def __init__(self, underlying_exception): - # just put the contextual representation of the error to hint on what - # went wrong without exposing internals - super(MarshallingException, self).__init__(unicode(underlying_exception)) - - -def is_indexable_but_not_string(obj): - return not hasattr(obj, "strip") and hasattr(obj, "__getitem__") - -def get_value(key, obj, default=None): - """Helper for pulling a keyed value off various types of objects""" - if is_indexable_but_not_string(obj): - try: - return obj[key] - except KeyError: - return default - return get_nested_value(key, obj, default) - - -def get_nested_value(key, obj, default): - if isinstance(key, basestring): - key = key.split('.') - if hasattr(obj, key[0]): - attr = getattr(obj, key.pop(0)) - if key: - return get_nested_value(key, attr, default) - return attr - return default - - - -def to_marshallable_type(obj): - """Helper for converting an object to a dictionary only if it is not - dictionary already or an indexable object nor a simple type""" - if obj is None: - return None # make it idempotent for None - - if hasattr(obj, '__getitem__'): - return obj # it is indexable it is ok - - if hasattr(obj, '__marshallable__'): - return obj.__marshallable__() - - return dict(obj.__dict__) - - -class Raw(object): - """Raw provides a base field class from which others should extend. It - applies no formatting by default, and should only be used in cases where - data does not need to be formatted before being serialized. Fields should - throw a MarshallingException in case of parsing problem. - """ - - def __init__(self, default=None, attribute=None): - self.attribute = attribute - self.default = default - - def format(self, value): - """Formats a field's value. No-op by default, concrete fields should - override this and apply the appropriate formatting. - - :param value: The value to format - :exception MarshallingException: In case of formatting problem - - Ex:: - - class TitleCase(Raw): - def format(self, value): - return unicode(value).title() - """ - return value - - def output(self, key, obj): - """Pulls the value for the given key from the object, applies the - field's formatting and returns the result. - :exception MarshallingException: In case of formatting problem - """ - value = get_value(key if self.attribute is None else self.attribute, obj) - - if value is None: - return self.default - - return self.format(value) - - -class Nested(Raw): - """Allows you to nest one set of fields inside another. - See :ref:`nested-field` for more information - - :param dict nested: The dictionary to nest - :param bool allow_null: Whether to return None instead of a dictionary - with null keys, if a nested dictionary has all-null keys - """ - - def __init__(self, nested, allow_null=False, **kwargs): - self.nested = nested - self.allow_null = allow_null - super(Nested, self).__init__(**kwargs) - - def output(self, key, obj): - data = to_marshallable_type(obj) - - attr = key if self.attribute is None else self.attribute - if self.allow_null and data.get(attr) is None: - return None - - return marshal(data[attr], self.nested) - -class List(Raw): - def __init__(self, cls_or_instance): - super(List, self).__init__() - if isinstance(cls_or_instance, type): - if not issubclass(cls_or_instance, Raw): - raise MarshallingException("The type of the list elements " - "must be a subclass of " - "flask_restful.fields.Raw") - self.container = cls_or_instance() - else: - if not isinstance(cls_or_instance, Raw): - raise MarshallingException("The instances of the list " - "elements must be of type " - "flask_restful.fields.Raw") - self.container = cls_or_instance - - def output(self, key, data): - value = get_value(key if self.attribute is None else self.attribute, data) - # we cannot really test for external dict behavior - if is_indexable_but_not_string(value) and not isinstance(value, dict): - # Convert all instances in typed list to container type - return [self.container.output(idx, value) for idx, val - in enumerate(value)] - - return [marshal(value, self.container.nested)] - - -class String(Raw): - def format(self, value): - try: - return unicode(value) - except ValueError as ve: - raise MarshallingException(ve) - - -class Integer(Raw): - def __init__(self, default=0, attribute=None): - super(Integer, self).__init__(default, attribute) - - def format(self, value): - try: - if value is None: - return self.default - return int(value) - except ValueError as ve: - raise MarshallingException(ve) - - -class Boolean(Raw): - def format(self, value): - return bool(value) - - -class FormattedString(Raw): - def __init__(self, src_str): - super(FormattedString, self).__init__() - self.src_str = unicode(src_str) - - def output(self, key, obj): - try: - data = to_marshallable_type(obj) - return self.src_str.format(**data) - except (TypeError, IndexError) as error: - raise MarshallingException(error) - - -class Url(Raw): - """ - A string representation of a Url - """ - def __init__(self, endpoint): - super(Url, self).__init__() - self.endpoint = endpoint - - def output(self, key, obj): - try: - data = to_marshallable_type(obj) - o = urlparse.urlparse(url_for(self.endpoint, **data)) - return urlparse.urlunparse(("", "", o.path, "", "", "")) - except TypeError as te: - raise MarshallingException(te) - - -class Float(Raw): - """ - A double as IEEE-754 double precision. - ex : 3.141592653589793 3.1415926535897933e-06 3.141592653589793e+24 nan inf -inf - """ - - def format(self, value): - try: - return repr(float(value)) - except ValueError as ve: - raise MarshallingException(ve) - - -class Arbitrary(Raw): - """ - A floating point number with an arbitrary precision - ex: 634271127864378216478362784632784678324.23432 - """ - - def format(self, value): - return unicode(MyDecimal(value)) - - -class DateTime(Raw): - """Return a RFC822-formatted datetime string in UTC""" - - def format(self, value): - try: - return types.rfc822(value) - except AttributeError as ae: - raise MarshallingException(ae) - -ZERO = MyDecimal() - -class Fixed(Raw): - def __init__(self, decimals=5): - super(Fixed, self).__init__() - self.precision = MyDecimal('0.' + '0' * (decimals - 1) + '1') - - def format(self, value): - dvalue = MyDecimal(value) - if not dvalue.is_normal() and dvalue != ZERO: - raise MarshallingException('Invalid Fixed precision number.') - return unicode(dvalue.quantize(self.precision, rounding=ROUND_HALF_EVEN)) - -Price = Fixed diff --git a/billy/utils/generic.py b/billy/utils/generic.py new file mode 100644 index 0000000..7bbe6d3 --- /dev/null +++ b/billy/utils/generic.py @@ -0,0 +1,64 @@ +from __future__ import unicode_literals + +import os +import uuid +import decimal + +B58_CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +B58_BASE = len(B58_CHARS) + + +def b58encode(s): + """Do a base 58 encoding (alike base 64, but in 58 char only) + + From https://bitcointalk.org/index.php?topic=1026.0 + + by Gavin Andresen (public domain) + + """ + value = 0 + for i, c in enumerate(reversed(s)): + value += ord(c) * (256 ** i) + + result = [] + while value >= B58_BASE: + div, mod = divmod(value, B58_BASE) + c = B58_CHARS[mod] + result.append(c) + value = div + result.append(B58_CHARS[value]) + return ''.join(reversed(result)) + + +def make_guid(): + """Generate a GUID and return in base58 encoded form + + """ + uid = uuid.uuid1().bytes + return b58encode(uid) + + +def make_api_key(size=32): + """Generate a random API key, should be as random as possible + (not predictable) + + :param size: the size in byte to generate + note that it will be encoded in base58 manner, + the length will be longer than the aksed size + """ + # TODO: os.urandom collect entropy from devices in linux, + # it might block when there is no enough entropy + # attacker might use this to perform a DOS attack + # maybe we can use another way to avoid such situation + # however, this is good enough currently + random = os.urandom(size) + return b58encode(random) + +def round_down_cent(amount): + """Round down money value to cent (truncate to), for example, $5.66666 + will be rounded to $5.66 + + :param amount: the money amount to be rounded + :return: the rounded money amount + """ + return amount.quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_DOWN) diff --git a/billy/utils/intervals.py b/billy/utils/intervals.py deleted file mode 100644 index 1ff0877..0000000 --- a/billy/utils/intervals.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import unicode_literals - -import json - -from dateutil.relativedelta import relativedelta -from wtforms import Field, TextField - -from billy.utils import fields - - -class Intervals(object): - - """ - A class to represent and create relativedelta objects which will be used - to define the plan intervals. ChargePlan intervals MUST be defined using this - class. - """ - NONE = relativedelta(seconds=0) - TWELVE_HOURS = relativedelta(hours=12) - DAY = relativedelta(days=1) - THREE_DAYS = relativedelta(days=3) - WEEK = relativedelta(weeks=1) - TWO_WEEKS = relativedelta(weeks=2) - THREE_WEEKS = relativedelta(weeks=3) - MONTH = relativedelta(months=1) - TWO_MONTHS = relativedelta(months=2) - THREE_MONTHS = relativedelta(months=3) - SIX_MONTHS = relativedelta(months=6) - NINE_MONTHS = relativedelta(months=9) - YEAR = relativedelta(years=1) - - @classmethod - def custom(cls, years=0, months=0, weeks=0, days=0, hours=0): - """ - If one of the predefined intervals isn't useful you can create a custom - plan interval with a resolution of up to a minute. - """ - return relativedelta( - years=years, months=months, weeks=weeks, days=days, - hours=hours) - - -def interval_matcher(string): - """ - This method takes a string and converts it to a interval object. - Functioning examples: - week two_weeks month three_months or a json with params: - years, months, weeks, days, hours - """ - if hasattr(Intervals, string.upper()): - return getattr(Intervals, string.upper()) - else: - try: - data = json.loads(string) - relativedelta( - years=int(data.get('years', 0)), - months=int(data.get('months', 0)), - weeks=int(data.get('weeks', 0)), - days=int(data.get('days', 0)), - hours=int(data.get('hours', 0)), - ) - except (ValueError, TypeError): - raise ValueError - - -class IntervalViewField(fields.Raw): - - def format(self, inter): - return { - 'years': inter.years, - 'months': inter.months, - 'days': inter.days, - 'hours': inter.hours, - } diff --git a/billy/utils/models.py b/billy/utils/models.py deleted file mode 100644 index 2c695ed..0000000 --- a/billy/utils/models.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import unicode_literals - -import random -import uuid - -from sqlalchemy import Enum - -ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' - -class Enum(Enum): - """ - Better sqlalchemy enum with a getattr - """ - def __getattr__(self, item): - if item in self.enums: - return item - raise ValueError('{} not set.'.format(item)) - - - -def base62_encode(num, alphabet=ALPHABET): - """Encode a number in Base X - - `num`: The number to encode - `alphabet`: The alphabet to use for encoding - """ - if num == 0: - return alphabet[0] - arr = [] - base = len(alphabet) - while num: - rem = num % base - num = num // base - arr.append(alphabet[rem]) - arr.reverse() - return ''.join(arr) - - -def uuid_factory(prefix=None): - """ - Given a prefix, which defaults to None, will generate a function - which when called, will generate a hex uuid string using uuid.uuid1() - - If a prefix string is passed, it prefixes the uuid. - """ - - def generate_uuid(): - the_uuid = base62_encode(uuid.uuid1().int) - if prefix: - the_uuid = prefix + the_uuid - - return the_uuid - - return generate_uuid - - -def api_key_factory(): - """ - TODO: Marsenne twister is predictable. Up the security - """ - - generator = lambda: ''.join([random.choice(ALPHABET) for _ in xrange(32)]) - return generator - - diff --git a/chef b/chef new file mode 160000 index 0000000..89ddc99 --- /dev/null +++ b/chef @@ -0,0 +1 @@ +Subproject commit 89ddc992e43fc33aab15601914c8d547529acb12 diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..b5c561b --- /dev/null +++ b/development.ini @@ -0,0 +1,73 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:billy + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/billy.sqlite + +billy.processor_factory = billy.models.processors.balanced_payments.BalancedProcessor + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, billy, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_billy] +level = DEBUG +handlers = +qualname = billy + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/distribute_setup.py b/distribute_setup.py index 36054b7..3553b21 100644 --- a/distribute_setup.py +++ b/distribute_setup.py @@ -553,4 +553,4 @@ def main(version=DEFAULT_VERSION): return _install(tarball, _build_install_args(options)) if __name__ == '__main__': - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/production.ini b/production.ini new file mode 100644 index 0000000..70bbb6e --- /dev/null +++ b/production.ini @@ -0,0 +1,64 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:billy + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_tm + +sqlalchemy.url = sqlite:///%(here)s/billy.sqlite + +billy.processor_factory = billy.models.processors.balanced_payments.BalancedProcessor + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, billy, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_billy] +level = WARN +handlers = +qualname = billy + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s diff --git a/requirements.txt b/requirements.txt index 0170817..95beb91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,12 @@ -Flask==0.10.1 -Flask-RESTful==0.2.3 -Flask-Script==0.5.3 -Jinja2==2.7 -MarkupSafe==0.18 -SQLAlchemy==0.8.1 -WTForms==1.0.4 -Werkzeug==0.9.1 -autopep8==0.9.1 -coverage==3.6 -forbiddenfruit==0.1.0 -freezegun==0.1.3 -glob2==0.4.1 -ipdb==0.7 -ipython==1.0.0 -itsdangerous==0.21 -jsonschema==2.0.0 -mock==1.0.1 +SQLAlchemy==0.8.2 +Zope.SQLAlchemy==0.7.2 nose==1.3.0 -pep8==1.4.5 -psycopg2==2.5 python-dateutil==1.5 +balanced==0.11.12 +pyramid==1.4.3 +waitress==0.8.6 +pyramid_debugtoolbar==1.0.6 +pyramid_tm==0.7 +iso8601==0.1.4 pytz==2013b -six==1.3.0 -wsgiref==0.1.2 +WTForms==1.0.4 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 4e8f33a..ee245dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,8 @@ [nosetests] match=^test +exclude-dir=billy/tests/integration nocapture=1 with-coverage=1 cover-package=billy cover-erase=1 -cover-html=1 \ No newline at end of file +cover-html=1 diff --git a/setup.py b/setup.py index a1a244d..c6eb9ae 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,8 @@ readme = open(os.path.join(here, 'README.md')).read() requires = open(os.path.join(here, 'requirements.txt')).read() requires = map(lambda r: r.strip(), requires.splitlines()) - +test_requires = open(os.path.join(here, 'test_requirements.txt')).read() +test_requires = map(lambda r: r.strip(), test_requires.splitlines()) setup( name='billy', @@ -29,9 +30,13 @@ packages=find_packages(), include_package_data=True, zip_safe=False, - tests_require=[ - 'nose-cov', - 'webtest' - ], install_requires=requires, + tests_require=test_requires, + entry_points="""\ + [paste.app_factory] + main = billy:main + [console_scripts] + initialize_billy_db = billy.scripts.initializedb:main + process_billy_tx = billy.scripts.process_transactions:main + """, ) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..bc8deb9 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,6 @@ +nose-cov +webtest +freezegun +flexmock +wsgiproxy2 +nose-exclude \ No newline at end of file