From c7eb8c6e9208a713ae4004f81e56f6db7c78ca04 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 30 Jul 2013 22:22:43 +0800 Subject: [PATCH 001/158] Fix #14, add setup.py and python distribute files --- MAINFEST.in | 2 +- distribute_setup.py | 2 +- setup.cfg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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/setup.cfg b/setup.cfg index 4e8f33a..1ea2b1b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,4 +4,4 @@ nocapture=1 with-coverage=1 cover-package=billy cover-erase=1 -cover-html=1 \ No newline at end of file +cover-html=1 From 0120ae7c9b65dfb025270bbc6e9424798bd35c3f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 31 Jul 2013 22:30:05 +0800 Subject: [PATCH 002/158] Use a better import style --- billy/settings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/billy/settings/__init__.py b/billy/settings/__init__.py index fb1ec28..d2e6d29 100644 --- a/billy/settings/__init__.py +++ b/billy/settings/__init__.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import os -DEBUG_MODE = os.environ.get('DEBUG_MODE', 'PROD') +DEBUG_MODE = os.environ.get('DEBUG_MODE', 'dev') DEBUG = True if DEBUG_MODE.lower() == 'dev' else False From 7de6f6fe39ad1bb480de80d931844e14540515d7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 1 Aug 2013 11:33:27 +0800 Subject: [PATCH 003/158] Fix tons of import issue --- billy/manage.py | 1 + billy/settings/debug.py | 17 +++++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/billy/manage.py b/billy/manage.py index 970c0f3..4625e1d 100644 --- a/billy/manage.py +++ b/billy/manage.py @@ -14,6 +14,7 @@ def create_tables(): """ Creates the tables if they dont exists """ + print '#'*10, 'Base.metadata', Base.metadata Base.metadata.create_all(DB_ENGINE) print "Create tables.... DONE" diff --git a/billy/settings/debug.py b/billy/settings/debug.py index 3fdd039..75214ed 100644 --- a/billy/settings/debug.py +++ b/billy/settings/debug.py @@ -7,20 +7,17 @@ from billy.utils.intervals import Intervals DB_SETTINGS = { - 'driver': 'postgresql', - 'host': 'localhost', - 'port': 5432, - 'user': 'test', - 'password': 'test', + 'driver': 'sqlite', + 'host': '//billy.db', '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_URL = URL(DB_SETTINGS['driver'], + host=DB_SETTINGS['host']) -DB_ENGINE = create_engine(DB_URL) +DB_URL = 'sqlite:///billy.db' + +DB_ENGINE = create_engine(DB_URL, echo=True) Session = scoped_session(sessionmaker(bind=DB_ENGINE)) # A list of attempt invervals, [ATTEMPT n DELAY INTERVAL,...] From d90fd5ac944f682e87f9581fa2917c90ade32680 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 19:36:49 +0800 Subject: [PATCH 004/158] Rollback some API descrpition in readme file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7163edb..2fe01c9 100644 --- a/README.md +++ b/README.md @@ -34,4 +34,4 @@ Check out the spec at api/spec.json, which is generated using api/spec.py #### Major Todos: - Redo import strucutre - Redo commit/flush/rollback handling. -- Better transient exception handling. \ No newline at end of file +- Better transient exception handling. From 8403639a072d8e88482d8b080b801cd623069c31 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 19:43:02 +0800 Subject: [PATCH 005/158] Remove unused file --- billy/models/#billy.uml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 billy/models/#billy.uml 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 - - - From 6a58756df6ecb71324e17753dedd6c44dba230b9 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 20:27:01 +0800 Subject: [PATCH 006/158] Add new model and tables module --- billy/models/plan.py | 33 +++++++++++++++++++++ billy/models/tables.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 billy/models/plan.py create mode 100644 billy/models/tables.py diff --git a/billy/models/plan.py b/billy/models/plan.py new file mode 100644 index 0000000..702361b --- /dev/null +++ b/billy/models/plan.py @@ -0,0 +1,33 @@ +import logging + +from billy.models import tables + + +class PlanModel(object): + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get_plan_by_guid(self, guid): + """Get a plan guid and return it + + """ + query = self.session.query(tables.Plan).get(guid) + return query + + def create_plan(self, name, amount): + """Create a plan and return its ID + + """ + plan = tables.Plan( + # TODO: generate GUID here + guid='', + name=name, + amount=amount, + ) + self.session.add(plan) + self.session.flush() + return plan.guid + + diff --git a/billy/models/tables.py b/billy/models/tables.py new file mode 100644 index 0000000..ce4cc27 --- /dev/null +++ b/billy/models/tables.py @@ -0,0 +1,67 @@ +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy import Unicode +from sqlalchemy import Boolean +from sqlalchemy import DateTime +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 = _now_func[0] + return func() + + +class Plan(DeclarativeBase): + __tablename__ = 'plan' + + guid = Column(String(64), primary_key=True) + + #: the external ID given by user + external_id = Column(Unicode(128), index=True) + + #: a short name of this plan + name = Column(Unicode(128)) + + #: the amount to bill user + # TODO: should use a decmial here as it is money unit? + amount = Column(Integer, nullable=False) + + #: is this plain active? + active = Column(Boolean, default=True, nullable=False) + + #: the created datetime of this plan + created_at = Column(DateTime(timezone=True), default=now_func) + + #: the updated datetime of this plan + updated_at = Column(DateTime(timezone=True), default=now_func) + + #: the fequency to bill user, 0=daily, 1=weekly, 2=monthly + # TODO: this is just a rough implementation, should allow + # a more flexiable setting later + frequency = Column(Integer, nullable=False) From 708b459c65201acf61f2bb7813233beeda08d91f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 21:54:31 +0800 Subject: [PATCH 007/158] Update utile --- billy/utils/models.py | 57 +++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/billy/utils/models.py b/billy/utils/models.py index 2c695ed..93a425d 100644 --- a/billy/utils/models.py +++ b/billy/utils/models.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals -import random +import os import uuid from sqlalchemy import Enum @@ -18,48 +18,35 @@ def __getattr__(self, item): -def base62_encode(num, alphabet=ALPHABET): - """Encode a number in Base X +def b58encode(s): + """From https://bitcointalk.org/index.php?topic=1026.0 - `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() + by Gavin Andresen (public domain) - If a prefix string is passed, it prefixes the uuid. """ + value = 0 + for i, c in enumerate(reversed(s)): + value += ord(c) * (256 ** i) - def generate_uuid(): - the_uuid = base62_encode(uuid.uuid1().int) - if prefix: - the_uuid = prefix + the_uuid + 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)) - return the_uuid - return generate_uuid +def make_guid(): + """Generate a GUID and return in base58 encoded form - -def api_key_factory(): - """ - TODO: Marsenne twister is predictable. Up the security """ + uid = uuid.uuid1().bytes + return b58encode(uid) - generator = lambda: ''.join([random.choice(ALPHABET) for _ in xrange(32)]) - return generator +def make_api_key(size=32): + """Generate a random API key, should be as random as possible + (not predictable) From ee99d2941fcf8757fefb50263bf4a63ac71fbb78 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 21:56:06 +0800 Subject: [PATCH 008/158] Clean up code --- billy/utils/__init__.py | 1 - billy/utils/intervals.py | 74 ---------------------------------------- billy/utils/models.py | 2 -- 3 files changed, 77 deletions(-) delete mode 100644 billy/utils/intervals.py 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/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 index 93a425d..c8486cc 100644 --- a/billy/utils/models.py +++ b/billy/utils/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import os import uuid From 5f002f1000f23ae507b384c21d20de07cc8309ca Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 21:59:33 +0800 Subject: [PATCH 009/158] Clean old database code (refectory and add them back later) --- billy/models/base.py | 63 ------------------------- billy/models/coupons.py | 96 --------------------------------------- billy/models/customers.py | 60 ------------------------ billy/models/plan.py | 1 - 4 files changed, 220 deletions(-) delete mode 100644 billy/models/base.py delete mode 100644 billy/models/coupons.py delete mode 100644 billy/models/customers.py 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/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/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/plan.py b/billy/models/plan.py index 702361b..85952e3 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -30,4 +30,3 @@ def create_plan(self, name, amount): self.session.flush() return plan.guid - From f55b65639584694cd8fe775047e9ba64bb3e6bc2 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 13:50:46 +0800 Subject: [PATCH 010/158] Clean requirements up --- requirements.txt | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0170817..0e52923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,3 @@ -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 -nose==1.3.0 -pep8==1.4.5 -psycopg2==2.5 -python-dateutil==1.5 -pytz==2013b -six==1.3.0 -wsgiref==0.1.2 +SQLAlchemy==0.8.2 +Zope.SQLAlchemy==0.7.2 +nose==1.3.0 \ No newline at end of file From 915b06382de2a445d1eb2dc10f1930a5a3e79135 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 13:50:26 +0800 Subject: [PATCH 011/158] Remove old code --- billy/api/__init__.py | 57 - billy/api/app.py | 15 - billy/api/errors/__init__.py | 34 - billy/api/errors/definitions.py | 180 --- billy/api/resources/__init__.py | 33 - billy/api/resources/base/__init__.py | 72 -- billy/api/resources/coupon/__init__.py | 76 -- billy/api/resources/coupon/form.py | 84 -- billy/api/resources/coupon/view.py | 17 - billy/api/resources/customer/__init__.py | 68 - billy/api/resources/customer/form.py | 37 - billy/api/resources/customer/view.py | 13 - billy/api/resources/group/__init__.py | 64 - billy/api/resources/group/view.py | 0 billy/api/resources/payout/__init__.py | 76 -- billy/api/resources/payout/form.py | 54 - billy/api/resources/payout/view.py | 14 - .../api/resources/payout_invoice/__init__.py | 45 - billy/api/resources/payout_invoice/view.py | 17 - .../resources/payout_subscription/__init__.py | 68 - .../api/resources/payout_subscription/form.py | 60 - .../api/resources/payout_subscription/view.py | 13 - .../resources/payout_transaction/__init__.py | 45 - .../api/resources/payout_transaction/view.py | 14 - billy/api/resources/plan/__init__.py | 76 -- billy/api/resources/plan/form.py | 61 - billy/api/resources/plan/view.py | 16 - billy/api/resources/plan_invoice/__init__.py | 45 - billy/api/resources/plan_invoice/view.py | 25 - .../resources/plan_subscription/__init__.py | 68 - billy/api/resources/plan_subscription/form.py | 60 - billy/api/resources/plan_subscription/view.py | 14 - .../resources/plan_transaction/__init__.py | 45 - billy/api/resources/plan_transaction/view.py | 14 - billy/api/spec.json | 1116 ----------------- billy/api/spec.py | 246 ---- billy/models/charge/__init__.py | 0 billy/models/charge/invoice.py | 153 --- billy/models/charge/plan.py | 94 -- billy/models/charge/subscription.py | 119 -- billy/models/charge/transaction.py | 45 - billy/models/payout/__init__.py | 0 billy/models/payout/invoice.py | 118 -- billy/models/payout/plan.py | 52 - billy/models/payout/subscription.py | 50 - billy/models/payout/transaction.py | 46 - billy/models/plan.py | 4 +- billy/models/processor/__init__.py | 13 - billy/models/processor/balanced.py | 43 - billy/models/processor/dummy.py | 43 - billy/settings/__init__.py | 12 - billy/settings/all.py | 1 - billy/settings/debug.py | 34 - billy/settings/prod.py | 0 billy/tests/fixtures/__init__.py | 8 - billy/tests/fixtures/company.py | 14 - billy/tests/fixtures/coupon.py | 19 - billy/tests/fixtures/customer.py | 10 - billy/tests/fixtures/payout.py | 15 - billy/tests/fixtures/plan.py | 18 - billy/tests/fixtures/schemas/customer.json | 33 - billy/tests/test_api/__init__.py | 93 -- billy/tests/test_api/test_coupon.py | 145 --- billy/tests/test_api/test_customer.py | 145 --- billy/tests/test_api/test_group_auth.py | 44 - .../tests/test_models/test_charge_invoice.py | 98 -- billy/tests/test_models/test_charge_plan.py | 49 - .../test_models/test_charge_subscription.py | 38 - .../test_models/test_charge_transaction.py | 37 - billy/tests/test_models/test_coupon.py | 52 - billy/tests/test_models/test_interface.py | 52 - billy/utils/fields.py | 254 ---- billy/utils/models.py | 50 - 73 files changed, 2 insertions(+), 4941 deletions(-) delete mode 100644 billy/api/__init__.py delete mode 100644 billy/api/app.py delete mode 100644 billy/api/errors/__init__.py delete mode 100644 billy/api/errors/definitions.py delete mode 100644 billy/api/resources/__init__.py delete mode 100644 billy/api/resources/base/__init__.py delete mode 100644 billy/api/resources/coupon/__init__.py delete mode 100644 billy/api/resources/coupon/form.py delete mode 100644 billy/api/resources/coupon/view.py delete mode 100644 billy/api/resources/customer/__init__.py delete mode 100644 billy/api/resources/customer/form.py delete mode 100644 billy/api/resources/customer/view.py delete mode 100644 billy/api/resources/group/__init__.py delete mode 100644 billy/api/resources/group/view.py delete mode 100644 billy/api/resources/payout/__init__.py delete mode 100644 billy/api/resources/payout/form.py delete mode 100644 billy/api/resources/payout/view.py delete mode 100644 billy/api/resources/payout_invoice/__init__.py delete mode 100644 billy/api/resources/payout_invoice/view.py delete mode 100644 billy/api/resources/payout_subscription/__init__.py delete mode 100644 billy/api/resources/payout_subscription/form.py delete mode 100644 billy/api/resources/payout_subscription/view.py delete mode 100644 billy/api/resources/payout_transaction/__init__.py delete mode 100644 billy/api/resources/payout_transaction/view.py delete mode 100644 billy/api/resources/plan/__init__.py delete mode 100644 billy/api/resources/plan/form.py delete mode 100644 billy/api/resources/plan/view.py delete mode 100644 billy/api/resources/plan_invoice/__init__.py delete mode 100644 billy/api/resources/plan_invoice/view.py delete mode 100644 billy/api/resources/plan_subscription/__init__.py delete mode 100644 billy/api/resources/plan_subscription/form.py delete mode 100644 billy/api/resources/plan_subscription/view.py delete mode 100644 billy/api/resources/plan_transaction/__init__.py delete mode 100644 billy/api/resources/plan_transaction/view.py delete mode 100644 billy/api/spec.json delete mode 100644 billy/api/spec.py delete mode 100644 billy/models/charge/__init__.py delete mode 100644 billy/models/charge/invoice.py delete mode 100644 billy/models/charge/plan.py delete mode 100644 billy/models/charge/subscription.py delete mode 100644 billy/models/charge/transaction.py delete mode 100644 billy/models/payout/__init__.py delete mode 100644 billy/models/payout/invoice.py delete mode 100644 billy/models/payout/plan.py delete mode 100644 billy/models/payout/subscription.py delete mode 100644 billy/models/payout/transaction.py delete mode 100644 billy/models/processor/__init__.py delete mode 100644 billy/models/processor/balanced.py delete mode 100644 billy/models/processor/dummy.py delete mode 100644 billy/settings/__init__.py delete mode 100644 billy/settings/all.py delete mode 100644 billy/settings/debug.py delete mode 100644 billy/settings/prod.py delete mode 100644 billy/tests/fixtures/__init__.py delete mode 100644 billy/tests/fixtures/company.py delete mode 100644 billy/tests/fixtures/coupon.py delete mode 100644 billy/tests/fixtures/customer.py delete mode 100644 billy/tests/fixtures/payout.py delete mode 100644 billy/tests/fixtures/plan.py delete mode 100644 billy/tests/fixtures/schemas/customer.json delete mode 100644 billy/tests/test_api/__init__.py delete mode 100644 billy/tests/test_api/test_coupon.py delete mode 100644 billy/tests/test_api/test_customer.py delete mode 100644 billy/tests/test_api/test_group_auth.py delete mode 100644 billy/tests/test_models/test_charge_invoice.py delete mode 100644 billy/tests/test_models/test_charge_plan.py delete mode 100644 billy/tests/test_models/test_charge_subscription.py delete mode 100644 billy/tests/test_models/test_charge_transaction.py delete mode 100644 billy/tests/test_models/test_coupon.py delete mode 100644 billy/tests/test_models/test_interface.py delete mode 100644 billy/utils/fields.py delete mode 100644 billy/utils/models.py diff --git a/billy/api/__init__.py b/billy/api/__init__.py deleted file mode 100644 index eccc432..0000000 --- a/billy/api/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -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 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/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/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/group/view.py b/billy/api/resources/group/view.py deleted file mode 100644 index e69de29..0000000 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/models/charge/__init__.py b/billy/models/charge/__init__.py deleted file mode 100644 index e69de29..0000000 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/payout/__init__.py b/billy/models/payout/__init__.py deleted file mode 100644 index e69de29..0000000 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 index 85952e3..4d96acb 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -1,6 +1,7 @@ import logging from billy.models import tables +from billy.utils.generic import make_guid class PlanModel(object): @@ -22,11 +23,10 @@ def create_plan(self, name, amount): """ plan = tables.Plan( # TODO: generate GUID here - guid='', + guid=make_guid(), name=name, amount=amount, ) self.session.add(plan) self.session.flush() return plan.guid - 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/settings/__init__.py b/billy/settings/__init__.py deleted file mode 100644 index d2e6d29..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', 'dev') -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 75214ed..0000000 --- a/billy/settings/debug.py +++ /dev/null @@ -1,34 +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': 'sqlite', - 'host': '//billy.db', - 'db_name': 'billy', -} - -DB_URL = URL(DB_SETTINGS['driver'], - host=DB_SETTINGS['host']) - -DB_URL = 'sqlite:///billy.db' - -DB_ENGINE = create_engine(DB_URL, echo=True) -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/settings/prod.py b/billy/settings/prod.py deleted file mode 100644 index e69de29..0000000 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_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_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/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/models.py b/billy/utils/models.py deleted file mode 100644 index c8486cc..0000000 --- a/billy/utils/models.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -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 b58encode(s): - """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) - From eeea898addf7a82c9ee3a1d0a3235e84f7c202a0 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 13:52:01 +0800 Subject: [PATCH 012/158] Implement a rough framework to continue work on --- billy/models/__init__.py | 45 +++++++++++++++-------- billy/models/plan.py | 15 ++++++-- billy/tests/__init__.py | 25 ------------- billy/tests/helper.py | 44 +++++++++++++++++++++++ billy/tests/test_models/test_plan.py | 26 ++++++++++++++ billy/utils/generic.py | 54 ++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 billy/tests/helper.py create mode 100644 billy/tests/test_models/test_plan.py create mode 100644 billy/utils/generic.py diff --git a/billy/models/__init__.py b/billy/models/__init__.py index d8fe2c5..0fd41d8 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -1,17 +1,34 @@ -from __future__ import unicode_literals +from sqlalchemy import engine_from_config +from sqlalchemy.orm import scoped_session +from sqlalchemy.orm import sessionmaker +from zope.sqlalchemy import ZopeTransactionExtension + +from sqlalchemy import engine_from_config +from sqlalchemy.orm import scoped_session +from sqlalchemy.orm import sessionmaker +from zope.sqlalchemy import ZopeTransactionExtension + -from .processor import ProcessorType -from .base import Base +def setup_database(**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'] + )) -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 + # SQLite does not support utc_timestamp function, therefore, we need to + # replace it with utcnow of datetime here + if settings['engine'].name == 'sqlite': + import datetime + from . import tables + tables.set_now_func(datetime.datetime.utcnow) + + return settings diff --git a/billy/models/plan.py b/billy/models/plan.py index 4d96acb..d6c95ef 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -6,6 +6,15 @@ class PlanModel(object): + FREQ_DAILY = 0 + FREQ_WEEKLY = 1 + FREQ_MONTHLY = 2 + FREQ_ALL = [ + FREQ_DAILY, + FREQ_WEEKLY, + FREQ_MONTHLY, + ] + def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session @@ -17,15 +26,17 @@ def get_plan_by_guid(self, guid): query = self.session.query(tables.Plan).get(guid) return query - def create_plan(self, name, amount): + def create_plan(self, name, amount, frequency): """Create a plan and return its ID """ + if frequency not in self.FREQ_ALL: + raise ValueError('Invalid frequency %s' % frequency) plan = tables.Plan( - # TODO: generate GUID here guid=make_guid(), name=name, amount=amount, + frequency=frequency, ) self.session.add(plan) self.session.flush() 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/helper.py b/billy/tests/helper.py new file mode 100644 index 0000000..c0658f3 --- /dev/null +++ b/billy/tests/helper.py @@ -0,0 +1,44 @@ +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 we 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 + engine = create_engine('sqlite:///', convert_unicode=True, echo=echo) + DeclarativeBase.metadata.bind = engine + DeclarativeBase.metadata.create_all(bind=engine) + + 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 + from billy.tests.helper import create_session + self.session = create_session() + self.now = datetime.datetime.utcnow() + self._old_now_func = tables.set_now_func(lambda: self.now) + + def tearDown(self): + from billy.models import tables + self.session.remove() + tables.set_now_func(self._old_now_func) diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py new file mode 100644 index 0000000..e49cfa2 --- /dev/null +++ b/billy/tests/test_models/test_plan.py @@ -0,0 +1,26 @@ +from billy.tests.helper import ModelTestCase + + +class TestPlanModel(ModelTestCase): + + def make_one(self, *args, **kwargs): + from billy.models.plan import PlanModel + return PlanModel(*args, **kwargs) + + def test_create_plan(self): + model = self.make_one(self.session) + name = 'monthly billing to user john' + amount = 5566.77 + frequency = model.FREQ_MONTHLY + guid = model.create_plan( + name=name, + amount=amount, + frequency=model.FREQ_MONTHLY, + ) + plan = model.get_plan_by_guid(guid) + self.assertEqual(plan.guid, guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.amount, amount) + self.assertEqual(plan.frequency, frequency) + self.assertEqual(plan.created_at, self.now) + self.assertEqual(plan.updated_at, self.now) diff --git a/billy/utils/generic.py b/billy/utils/generic.py new file mode 100644 index 0000000..964f3ed --- /dev/null +++ b/billy/utils/generic.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals + +import os +import uuid + +B58_CHARS = b'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 b''.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) From 7522759579efbfdd2e059cecf5022238cabc971d Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 6 Aug 2013 23:25:41 +0800 Subject: [PATCH 013/158] Add more tests for plan model --- billy/tests/test_models/test_plan.py | 32 ++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index e49cfa2..9d7d100 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -9,18 +9,46 @@ def make_one(self, *args, **kwargs): def test_create_plan(self): model = self.make_one(self.session) - name = 'monthly billing to user john' + name = 'monthly billing to user John' amount = 5566.77 frequency = model.FREQ_MONTHLY guid = model.create_plan( name=name, amount=amount, - frequency=model.FREQ_MONTHLY, + frequency=frequency, ) plan = model.get_plan_by_guid(guid) self.assertEqual(plan.guid, guid) self.assertEqual(plan.name, name) self.assertEqual(plan.amount, amount) self.assertEqual(plan.frequency, frequency) + self.assertEqual(plan.active, True) self.assertEqual(plan.created_at, self.now) self.assertEqual(plan.updated_at, self.now) + + def test_create_plan_with_wrong_frequency(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create_plan( + name=None, + amount=999, + frequency=999, + ) + + def test_get_plan(self): + model = self.make_one(self.session) + name = 'evil gangster charges protection fee from Tom weekly' + amount = 99.99 + frequency = model.FREQ_WEEKLY + guid = model.create_plan( + name=name, + amount=amount, + frequency=frequency, + ) + + plan = model.get_plan_by_guid('not-exist') + self.assertEqual(plan, None) + + plan = model.get_plan_by_guid(guid) + self.assertNotEqual(plan, None) \ No newline at end of file From e23462df9beeada7e5a481950ea7332530a201b7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 7 Aug 2013 18:31:40 +0800 Subject: [PATCH 014/158] Add update plan in plan model, improve test --- billy/models/plan.py | 23 ++++++++++++-- billy/tests/test_models/test_plan.py | 46 ++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index d6c95ef..172f3d4 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -19,14 +19,16 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_plan_by_guid(self, guid): + def get_plan_by_guid(self, guid, raise_error=True): """Get a plan 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).get(guid) return query - def create_plan(self, name, amount, frequency): + def create_plan(self, name, amount, frequency, active=True): """Create a plan and return its ID """ @@ -37,7 +39,24 @@ def create_plan(self, name, amount, frequency): name=name, amount=amount, frequency=frequency, + active=active, ) self.session.add(plan) self.session.flush() return plan.guid + + def update_plan(self, guid, **kwargs): + """Update a plan + + """ + plan = self.get_plan_by_guid(guid, True) + if 'name' in kwargs: + plan.name = kwargs['name'] + del kwargs['name'] + if 'active' in kwargs: + plan.active = kwargs['active'] + del kwargs['active'] + if kwargs: + raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) + self.session.add(plan) + self.session.flush() diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 9d7d100..e6640e9 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -37,18 +37,46 @@ def test_create_plan_with_wrong_frequency(self): ) def test_get_plan(self): + import transaction model = self.make_one(self.session) - name = 'evil gangster charges protection fee from Tom weekly' - amount = 99.99 - frequency = model.FREQ_WEEKLY - guid = model.create_plan( - name=name, - amount=amount, - frequency=frequency, - ) + + with transaction.manager: + guid = model.create_plan( + name='evil gangster charges protection fee from Tom weekly', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) plan = model.get_plan_by_guid('not-exist') self.assertEqual(plan, None) plan = model.get_plan_by_guid(guid) - self.assertNotEqual(plan, None) \ No newline at end of file + self.assertNotEqual(plan, None) + + def test_update_plan(self): + import transaction + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_plan( + name='evil gangster charges protection fee from Tom weekly', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + + name = 'new plan name' + active = False + + with transaction.manager: + model.update_plan( + guid=guid, + name=name, + active=active, + ) + + plan = model.get_plan_by_guid(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.active, active) + + with self.assertRaises(TypeError): + model.update_plan(guid, wrong_arg=True, neme='john') From 9d47dcae25820f999d9a13013b8f6fd2a6a76fd4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 7 Aug 2013 18:57:52 +0800 Subject: [PATCH 015/158] Update plan model --- billy/models/plan.py | 3 +++ billy/tests/test_models/test_plan.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/billy/models/plan.py b/billy/models/plan.py index 172f3d4..8612237 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -50,6 +50,9 @@ def update_plan(self, guid, **kwargs): """ plan = self.get_plan_by_guid(guid, True) + if kwargs: + now = tables.now_func() + plan.updated_at = now if 'name' in kwargs: plan.name = kwargs['name'] del kwargs['name'] diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index e6640e9..e22fa80 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -1,3 +1,5 @@ +import datetime + from billy.tests.helper import ModelTestCase @@ -66,6 +68,8 @@ def test_update_plan(self): name = 'new plan name' active = False + # advanced the current date time + self.now += datetime.timedelta(seconds=10) with transaction.manager: model.update_plan( @@ -77,6 +81,7 @@ def test_update_plan(self): plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) self.assertEqual(plan.active, active) + self.assertEqual(plan.updated_at, self.now) with self.assertRaises(TypeError): model.update_plan(guid, wrong_arg=True, neme='john') From 46ea7b6028f97e4b4d62c5b1d90700b3a3844340 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 7 Aug 2013 19:02:28 +0800 Subject: [PATCH 016/158] Improve plan model --- billy/models/plan.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index 8612237..c1cce2e 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -9,10 +9,12 @@ class PlanModel(object): FREQ_DAILY = 0 FREQ_WEEKLY = 1 FREQ_MONTHLY = 2 + FREQ_YEARLY = 3 FREQ_ALL = [ FREQ_DAILY, FREQ_WEEKLY, FREQ_MONTHLY, + FREQ_YEARLY, ] def __init__(self, session, logger=None): @@ -53,12 +55,11 @@ def update_plan(self, guid, **kwargs): if kwargs: now = tables.now_func() plan.updated_at = now - if 'name' in kwargs: - plan.name = kwargs['name'] - del kwargs['name'] - if 'active' in kwargs: - plan.active = kwargs['active'] - del kwargs['active'] + for key in ['name', 'active']: + 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) From b3972f8ea177ad23bbed9da5c6d58c579e25f7f7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 7 Aug 2013 19:18:00 +0800 Subject: [PATCH 017/158] Add HTML test coverage output --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From d53479ca200ffe69f98e7824506d20f37a0b385c Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 7 Aug 2013 19:25:33 +0800 Subject: [PATCH 018/158] Update plan model --- billy/models/plan.py | 7 +++---- billy/tests/helper.py | 2 +- billy/tests/test_models/test_plan.py | 19 ++++++++++++++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index c1cce2e..17b91a9 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -35,7 +35,7 @@ def create_plan(self, name, amount, frequency, active=True): """ if frequency not in self.FREQ_ALL: - raise ValueError('Invalid frequency %s' % frequency) + raise ValueError('Invalid frequency {}'.format(frequency)) plan = tables.Plan( guid=make_guid(), name=name, @@ -52,9 +52,8 @@ def update_plan(self, guid, **kwargs): """ plan = self.get_plan_by_guid(guid, True) - if kwargs: - now = tables.now_func() - plan.updated_at = now + now = tables.now_func() + plan.updated_at = now for key in ['name', 'active']: if key not in kwargs: continue diff --git a/billy/tests/helper.py b/billy/tests/helper.py index c0658f3..81a3e66 100644 --- a/billy/tests/helper.py +++ b/billy/tests/helper.py @@ -6,7 +6,7 @@ def create_session(echo=False): """Create engine and session for testing, return session then """ - # NOTICE: we do all imports here because we don't we to + # 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 diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index e22fa80..4d95b25 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -66,10 +66,10 @@ def test_update_plan(self): frequency=model.FREQ_WEEKLY, ) - name = 'new plan name' - active = False # advanced the current date time self.now += datetime.timedelta(seconds=10) + name = 'new plan name' + active = False with transaction.manager: model.update_plan( @@ -83,5 +83,18 @@ def test_update_plan(self): self.assertEqual(plan.active, active) self.assertEqual(plan.updated_at, self.now) + # advanced the current date time + self.now += datetime.timedelta(seconds=10) + + # this should update the updated_at field only + with transaction.manager: + model.update_plan(guid) + + plan = model.get_plan_by_guid(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.active, active) + self.assertEqual(plan.updated_at, self.now) + + # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_plan(guid, wrong_arg=True, neme='john') + model.update_plan(guid, wrong_arg=True, neme='john') \ No newline at end of file From 3baba378d9397f52059f784b1ab174f42564b0e4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 21:35:32 +0800 Subject: [PATCH 019/158] Update plan model --- billy/models/plan.py | 26 ++++++++++++++++++++---- billy/models/tables.py | 18 +++++++++++++---- billy/tests/helper.py | 1 + billy/tests/test_models/test_plan.py | 30 +++++++++++++++++++++------- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index 17b91a9..4061ba0 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import logging from billy.models import tables @@ -6,10 +7,15 @@ 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, @@ -17,6 +23,16 @@ class PlanModel(object): 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 @@ -30,18 +46,20 @@ def get_plan_by_guid(self, guid, raise_error=True): query = self.session.query(tables.Plan).get(guid) return query - def create_plan(self, name, amount, frequency, active=True): + def create_plan(self, plan_type, amount, frequency, name=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)) plan = tables.Plan( - guid=make_guid(), + guid='PL' + make_guid(), + plan_type=plan_type, name=name, amount=amount, frequency=frequency, - active=active, ) self.session.add(plan) self.session.flush() @@ -54,7 +72,7 @@ def update_plan(self, guid, **kwargs): plan = self.get_plan_by_guid(guid, True) now = tables.now_func() plan.updated_at = now - for key in ['name', 'active']: + for key in ['name']: if key not in kwargs: continue value = kwargs.pop(key) diff --git a/billy/models/tables.py b/billy/models/tables.py index ce4cc27..e6802fe 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -1,9 +1,12 @@ +from __future__ import unicode_literals + from sqlalchemy import Column from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy import Unicode from sqlalchemy import Boolean from sqlalchemy import DateTime +from sqlalchemy import Numeric from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import func @@ -38,10 +41,17 @@ def now_func(): class Plan(DeclarativeBase): + """Plan is a recurring payment schedule, such as a hosting service plan. + + """ + __tablename__ = 'plan' guid = Column(String(64), primary_key=True) + #: 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) @@ -49,11 +59,11 @@ class Plan(DeclarativeBase): name = Column(Unicode(128)) #: the amount to bill user - # TODO: should use a decmial here as it is money unit? - amount = Column(Integer, nullable=False) + # TODO: make sure how many digi of number we need + amount = Column(Numeric(10, 2), nullable=False) - #: is this plain active? - active = Column(Boolean, default=True, nullable=False) + #: is this plan deleted? + deleted = Column(Boolean, default=False, nullable=False) #: the created datetime of this plan created_at = Column(DateTime(timezone=True), default=now_func) diff --git a/billy/tests/helper.py b/billy/tests/helper.py index 81a3e66..487001a 100644 --- a/billy/tests/helper.py +++ b/billy/tests/helper.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import unittest import datetime diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 4d95b25..848ee7e 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -1,4 +1,6 @@ +from __future__ import unicode_literals import datetime +import decimal from billy.tests.helper import ModelTestCase @@ -12,19 +14,23 @@ def make_one(self, *args, **kwargs): def test_create_plan(self): model = self.make_one(self.session) name = 'monthly billing to user John' - amount = 5566.77 + amount = decimal.Decimal('5566.77') frequency = model.FREQ_MONTHLY + plan_type = model.TYPE_CHARGE guid = model.create_plan( + plan_type=plan_type, name=name, amount=amount, frequency=frequency, ) plan = model.get_plan_by_guid(guid) self.assertEqual(plan.guid, guid) + self.assert_(plan.guid.startswith('PL')) self.assertEqual(plan.name, name) self.assertEqual(plan.amount, amount) self.assertEqual(plan.frequency, frequency) - self.assertEqual(plan.active, True) + self.assertEqual(plan.plan_type, plan_type) + self.assertEqual(plan.deleted, False) self.assertEqual(plan.created_at, self.now) self.assertEqual(plan.updated_at, self.now) @@ -33,17 +39,30 @@ def test_create_plan_with_wrong_frequency(self): with self.assertRaises(ValueError): model.create_plan( + plan_type=model.TYPE_CHARGE, name=None, amount=999, frequency=999, ) + def test_create_plan_with_wrong_type(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create_plan( + plan_type=999, + name=None, + amount=999, + frequency=model.FREQ_DAILY, + ) + def test_get_plan(self): import transaction model = self.make_one(self.session) with transaction.manager: guid = model.create_plan( + plan_type=model.TYPE_CHARGE, name='evil gangster charges protection fee from Tom weekly', amount=99.99, frequency=model.FREQ_WEEKLY, @@ -61,6 +80,7 @@ def test_update_plan(self): with transaction.manager: guid = model.create_plan( + plan_type=model.TYPE_CHARGE, name='evil gangster charges protection fee from Tom weekly', amount=99.99, frequency=model.FREQ_WEEKLY, @@ -69,18 +89,15 @@ def test_update_plan(self): # advanced the current date time self.now += datetime.timedelta(seconds=10) name = 'new plan name' - active = False with transaction.manager: model.update_plan( guid=guid, name=name, - active=active, ) plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.active, active) self.assertEqual(plan.updated_at, self.now) # advanced the current date time @@ -92,9 +109,8 @@ def test_update_plan(self): plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.active, active) self.assertEqual(plan.updated_at, self.now) # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_plan(guid, wrong_arg=True, neme='john') \ No newline at end of file + model.update_plan(guid, wrong_arg=True, neme='john') From df26b7a656e2fc5710bfda601bb33aa5e9d58086 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 21:36:54 +0800 Subject: [PATCH 020/158] Add missing transaction in test_plan module --- billy/tests/test_models/test_plan.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 848ee7e..005d28d 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -12,17 +12,21 @@ def make_one(self, *args, **kwargs): return PlanModel(*args, **kwargs) def test_create_plan(self): + import transaction 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 - guid = model.create_plan( - plan_type=plan_type, - name=name, - amount=amount, - frequency=frequency, - ) + + with transaction.manager: + guid = model.create_plan( + plan_type=plan_type, + name=name, + amount=amount, + frequency=frequency, + ) + plan = model.get_plan_by_guid(guid) self.assertEqual(plan.guid, guid) self.assert_(plan.guid.startswith('PL')) From 0560c8dddc73d392c6124c170d87d4e319f30ee5 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 21:47:08 +0800 Subject: [PATCH 021/158] Use freezegun for testing datetime --- billy/tests/helper.py | 3 +- billy/tests/test_models/test_plan.py | 46 +++++++++++++++------------- setup.py | 3 +- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/billy/tests/helper.py b/billy/tests/helper.py index 487001a..fe0beb6 100644 --- a/billy/tests/helper.py +++ b/billy/tests/helper.py @@ -36,8 +36,7 @@ def setUp(self): from billy.models import tables from billy.tests.helper import create_session self.session = create_session() - self.now = datetime.datetime.utcnow() - self._old_now_func = tables.set_now_func(lambda: self.now) + self._old_now_func = tables.set_now_func(datetime.datetime.utcnow) def tearDown(self): from billy.models import tables diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 005d28d..fdf4824 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -2,9 +2,13 @@ import datetime import decimal +import transaction +from freezegun import freeze_time + from billy.tests.helper import ModelTestCase +@freeze_time('2013-08-16') class TestPlanModel(ModelTestCase): def make_one(self, *args, **kwargs): @@ -12,7 +16,6 @@ def make_one(self, *args, **kwargs): return PlanModel(*args, **kwargs) def test_create_plan(self): - import transaction model = self.make_one(self.session) name = 'monthly billing to user John' amount = decimal.Decimal('5566.77') @@ -26,7 +29,9 @@ def test_create_plan(self): amount=amount, frequency=frequency, ) - + + now = datetime.datetime.utcnow() + plan = model.get_plan_by_guid(guid) self.assertEqual(plan.guid, guid) self.assert_(plan.guid.startswith('PL')) @@ -35,8 +40,8 @@ def test_create_plan(self): self.assertEqual(plan.frequency, frequency) self.assertEqual(plan.plan_type, plan_type) self.assertEqual(plan.deleted, False) - self.assertEqual(plan.created_at, self.now) - self.assertEqual(plan.updated_at, self.now) + self.assertEqual(plan.created_at, now) + self.assertEqual(plan.updated_at, now) def test_create_plan_with_wrong_frequency(self): model = self.make_one(self.session) @@ -61,7 +66,6 @@ def test_create_plan_with_wrong_type(self): ) def test_get_plan(self): - import transaction model = self.make_one(self.session) with transaction.manager: @@ -79,7 +83,6 @@ def test_get_plan(self): self.assertNotEqual(plan, None) def test_update_plan(self): - import transaction model = self.make_one(self.session) with transaction.manager: @@ -90,30 +93,31 @@ def test_update_plan(self): frequency=model.FREQ_WEEKLY, ) - # advanced the current date time - self.now += datetime.timedelta(seconds=10) name = 'new plan name' - with transaction.manager: - model.update_plan( - guid=guid, - name=name, - ) + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with transaction.manager: + model.update_plan( + guid=guid, + name=name, + ) + updated_time = datetime.datetime.utcnow() plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.updated_at, self.now) + self.assertEqual(plan.updated_at, updated_time) - # advanced the current date time - self.now += datetime.timedelta(seconds=10) - - # this should update the updated_at field only - with transaction.manager: - model.update_plan(guid) + # 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_plan(guid) + updated_time = datetime.datetime.utcnow() plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.updated_at, self.now) + self.assertEqual(plan.updated_at, updated_time) # make sure passing wrong argument will raise error with self.assertRaises(TypeError): diff --git a/setup.py b/setup.py index a1a244d..f12dcc9 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,8 @@ zip_safe=False, tests_require=[ 'nose-cov', - 'webtest' + 'webtest', + 'freezegun', ], install_requires=requires, ) From 4afd87cf83e11ae966e494b94ee4e78baebdf8dd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 22:01:30 +0800 Subject: [PATCH 022/158] Add external id and description fields to plan model --- billy/models/plan.py | 16 +++++++-- billy/models/tables.py | 5 +++ billy/tests/test_models/test_plan.py | 49 +++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index 4061ba0..d0ca571 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -46,7 +46,15 @@ def get_plan_by_guid(self, guid, raise_error=True): query = self.session.query(tables.Plan).get(guid) return query - def create_plan(self, plan_type, amount, frequency, name=None): + def create_plan( + self, + plan_type, + amount, + frequency, + external_id=None, + name=None, + description=None, + ): """Create a plan and return its ID """ @@ -57,9 +65,11 @@ def create_plan(self, plan_type, amount, frequency, name=None): plan = tables.Plan( guid='PL' + make_guid(), plan_type=plan_type, - name=name, amount=amount, frequency=frequency, + external_id=external_id, + name=name, + description=description, ) self.session.add(plan) self.session.flush() @@ -72,7 +82,7 @@ def update_plan(self, guid, **kwargs): plan = self.get_plan_by_guid(guid, True) now = tables.now_func() plan.updated_at = now - for key in ['name']: + for key in ['name', 'external_id', 'description']: if key not in kwargs: continue value = kwargs.pop(key) diff --git a/billy/models/tables.py b/billy/models/tables.py index e6802fe..6e61f3e 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -4,6 +4,7 @@ from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy import Unicode +from sqlalchemy import UnicodeText from sqlalchemy import Boolean from sqlalchemy import DateTime from sqlalchemy import Numeric @@ -58,8 +59,12 @@ class Plan(DeclarativeBase): #: a short name of this plan name = Column(Unicode(128)) + #: a long description of this plan + description = Column(UnicodeText(1024)) + #: 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) #: is this plan deleted? diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index fdf4824..9d4d982 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -21,6 +21,8 @@ def test_create_plan(self): amount = decimal.Decimal('5566.77') frequency = model.FREQ_MONTHLY plan_type = model.TYPE_CHARGE + external_id = '5566_GOOD_BROTHERS' + description = 'This is a long description' with transaction.manager: guid = model.create_plan( @@ -28,6 +30,8 @@ def test_create_plan(self): name=name, amount=amount, frequency=frequency, + external_id=external_id, + description=description, ) now = datetime.datetime.utcnow() @@ -39,6 +43,8 @@ def test_create_plan(self): self.assertEqual(plan.amount, amount) self.assertEqual(plan.frequency, frequency) 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) @@ -85,6 +91,37 @@ def test_get_plan(self): def test_update_plan(self): model = self.make_one(self.session) + with transaction.manager: + guid = model.create_plan( + 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_plan_by_guid(guid) + name = 'new name' + description = 'new description' + external_id = 'new external id' + + with transaction.manager: + model.update_plan( + guid=guid, + name=name, + description=description, + external_id=external_id, + ) + + plan = model.get_plan_by_guid(guid) + self.assertEqual(plan.name, name) + self.assertEqual(plan.description, description) + self.assertEqual(plan.external_id, external_id) + + def test_update_plan_updated_at(self): + model = self.make_one(self.session) + with transaction.manager: guid = model.create_plan( plan_type=model.TYPE_CHARGE, @@ -93,6 +130,8 @@ def test_update_plan(self): frequency=model.FREQ_WEEKLY, ) + plan = model.get_plan_by_guid(guid) + created_at = plan.created_at name = 'new plan name' # advanced the current date time @@ -102,22 +141,24 @@ def test_update_plan(self): guid=guid, name=name, ) - updated_time = datetime.datetime.utcnow() + updated_at = datetime.datetime.utcnow() plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.updated_at, updated_time) + 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_plan(guid) - updated_time = datetime.datetime.utcnow() + updated_at = datetime.datetime.utcnow() plan = model.get_plan_by_guid(guid) self.assertEqual(plan.name, name) - self.assertEqual(plan.updated_at, updated_time) + self.assertEqual(plan.updated_at, updated_at) + self.assertEqual(plan.created_at, created_at) # make sure passing wrong argument will raise error with self.assertRaises(TypeError): From 6241186a6a7aa23f9b37fa6b622b356fa061aa83 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 22:07:24 +0800 Subject: [PATCH 023/158] Implement plan delete --- billy/models/plan.py | 16 ++++++++++++++-- billy/tests/test_models/test_plan.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index d0ca571..029ea5a 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -37,13 +37,16 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_plan_by_guid(self, guid, raise_error=True): + def get_plan_by_guid(self, guid, raise_error=True, ignore_deleted=True): """Get a plan 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).get(guid) + query = self.session.query(tables.Plan) \ + .filter_by(guid=guid) \ + .filter_by(deleted=not ignore_deleted) \ + .first() return query def create_plan( @@ -91,3 +94,12 @@ def update_plan(self, guid, **kwargs): raise TypeError('Unknown attributes {} to update'.format(tuple(kwargs.keys()))) self.session.add(plan) self.session.flush() + + def delete_plan(self, guid): + """Delete a plan + + """ + plan = self.get_plan_by_guid(guid, True) + plan.deleted = True + self.session.add(plan) + self.session.flush() diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 9d4d982..e59af80 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -163,3 +163,23 @@ def test_update_plan_updated_at(self): # make sure passing wrong argument will raise error with self.assertRaises(TypeError): model.update_plan(guid, wrong_arg=True, neme='john') + + def test_delete_plan(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_plan( + plan_type=model.TYPE_CHARGE, + name='old name', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + + with transaction.manager: + model.delete_plan(guid) + + plan = model.get_plan_by_guid(guid) + self.assertEqual(plan, None) + + plan = model.get_plan_by_guid(guid, ignore_deleted=False) + self.assertEqual(plan.deleted, True) From e6fa7a752fe9b92dc8a07a9b70365efdba42568c Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 22:15:21 +0800 Subject: [PATCH 024/158] Add test for get_plan_by_guid --- billy/models/plan.py | 8 +++-- billy/tests/test_models/test_plan.py | 45 +++++++++++++++++----------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index 029ea5a..ede3134 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -37,7 +37,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_plan_by_guid(self, guid, raise_error=True, ignore_deleted=True): + def get_plan_by_guid(self, guid, raise_error=False, ignore_deleted=True): """Get a plan guid and return it :param guid: The guild of plan to get @@ -47,6 +47,8 @@ def get_plan_by_guid(self, guid, raise_error=True, ignore_deleted=True): .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_plan( @@ -82,7 +84,7 @@ def update_plan(self, guid, **kwargs): """Update a plan """ - plan = self.get_plan_by_guid(guid, True) + plan = self.get_plan_by_guid(guid, raise_error=True) now = tables.now_func() plan.updated_at = now for key in ['name', 'external_id', 'description']: @@ -99,7 +101,7 @@ def delete_plan(self, guid): """Delete a plan """ - plan = self.get_plan_by_guid(guid, True) + plan = self.get_plan_by_guid(guid, raise_error=True) plan.deleted = True self.session.add(plan) self.session.flush() diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index e59af80..1994749 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -15,6 +15,32 @@ 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_plan_by_guid('PL_NON_EXIST') + self.assertEqual(plan, None) + + with self.assertRaises(KeyError): + model.get_plan_by_guid('PL_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create_plan( + plan_type=model.TYPE_CHARGE, + name='name', + amount=99.99, + frequency=model.FREQ_WEEKLY, + ) + + with transaction.manager: + model.delete_plan(guid) + + with self.assertRaises(KeyError): + model.get_plan_by_guid(guid, raise_error=True) + + plan = model.get_plan_by_guid(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(plan.guid, guid) + def test_create_plan(self): model = self.make_one(self.session) name = 'monthly billing to user John' @@ -71,23 +97,6 @@ def test_create_plan_with_wrong_type(self): frequency=model.FREQ_DAILY, ) - def test_get_plan(self): - model = self.make_one(self.session) - - with transaction.manager: - guid = model.create_plan( - plan_type=model.TYPE_CHARGE, - name='evil gangster charges protection fee from Tom weekly', - amount=99.99, - frequency=model.FREQ_WEEKLY, - ) - - plan = model.get_plan_by_guid('not-exist') - self.assertEqual(plan, None) - - plan = model.get_plan_by_guid(guid) - self.assertNotEqual(plan, None) - def test_update_plan(self): model = self.make_one(self.session) @@ -170,7 +179,7 @@ def test_delete_plan(self): with transaction.manager: guid = model.create_plan( plan_type=model.TYPE_CHARGE, - name='old name', + name='name', amount=99.99, frequency=model.FREQ_WEEKLY, ) From 83581db00c74f7eeb137dede2d0abc7d554679ab Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 22:36:28 +0800 Subject: [PATCH 025/158] Add tests for generic utils --- billy/tests/test_utils/test_generic.py | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 billy/tests/test_utils/test_generic.py diff --git a/billy/tests/test_utils/test_generic.py b/billy/tests/test_utils/test_generic.py new file mode 100644 index 0000000..3ad036d --- /dev/null +++ b/billy/tests/test_utils/test_generic.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals +import math +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(b'\00', b'1') + assert_encode(b'hello world', b'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 check_size(key_size): + real_num = math.log((2 ** (8 * key_size)), 58) + expected_encoded_size = int(math.ceil(real_num)) + self.assertEqual(len(make_api_key(key_size)), expected_encoded_size) + + check_size(32) + check_size(100) + check_size(256) From ab5ae50cfaff1cb4312b46c437b22a990f97330b Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 23:04:44 +0800 Subject: [PATCH 026/158] Update api key generation test --- billy/tests/test_utils/test_generic.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/billy/tests/test_utils/test_generic.py b/billy/tests/test_utils/test_generic.py index 3ad036d..96d837f 100644 --- a/billy/tests/test_utils/test_generic.py +++ b/billy/tests/test_utils/test_generic.py @@ -11,6 +11,7 @@ def test_make_b58encode(self): def assert_encode(data, expected): self.assertEqual(b58encode(data), expected) + assert_encode(b'', b'1') assert_encode(b'\00', b'1') assert_encode(b'hello world', b'StV1DL6CwTryKyV') @@ -27,12 +28,3 @@ def test_make_api_key(self): # just make sure it is random api_keys = [make_api_key() for _ in range(1000)] self.assertEqual(len(set(api_keys)), 1000) - - def check_size(key_size): - real_num = math.log((2 ** (8 * key_size)), 58) - expected_encoded_size = int(math.ceil(real_num)) - self.assertEqual(len(make_api_key(key_size)), expected_encoded_size) - - check_size(32) - check_size(100) - check_size(256) From 7c16a02b36732976e872c8f1bc29f84a09a437bd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 13:51:46 +0800 Subject: [PATCH 027/158] Add company model --- billy/models/company.py | 212 ++++++------------------ billy/models/plan.py | 2 +- billy/models/tables.py | 39 +++-- billy/tests/test_models/test_company.py | 139 ++++++++++++---- billy/tests/test_models/test_plan.py | 4 - 5 files changed, 187 insertions(+), 209 deletions(-) diff --git a/billy/models/company.py b/billy/models/company.py index d9dafe7..1899d09 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -1,179 +1,67 @@ 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_company_by_guid(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 create_company(self, processor_key, name=None): + """Create a company and return its id - 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 + company = tables.Company( + guid='CP' + make_guid(), + processor_key=processor_key, + api_key=make_api_key(), + name=name, ) - self.session.add(customer) - return customer + self.session.add(company) + self.session.flush() + return company.guid - 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 + def update_company(self, guid, **kwargs): + """Update a company - - 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 - ) - self.session.add(plan) - return plan + company = self.get_company_by_guid(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_company(self, guid): + """Delete a company - 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 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 """ - return processor_map[self.processor_type](self.processor_credential) + company = self.get_company_by_guid(guid, raise_error=True) + company.deleted = True + self.session.add(company) + self.session.flush() diff --git a/billy/models/plan.py b/billy/models/plan.py index ede3134..53bce58 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -38,7 +38,7 @@ def __init__(self, session, logger=None): self.session = session def get_plan_by_guid(self, guid, raise_error=False, ignore_deleted=True): - """Get a plan guid and return it + """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 diff --git a/billy/models/tables.py b/billy/models/tables.py index 6e61f3e..66d7232 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -41,42 +41,53 @@ def now_func(): return func() +class Company(DeclarativeBase): + """A Company is basically a user to billy system + + """ + __tablename__ = 'company' + + guid = Column(String(64), primary_key=True) + #: the API key for accessing billy system + api_key = Column(String(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(timezone=True), default=now_func) + #: the updated datetime of this company + updated_at = Column(DateTime(timezone=True), default=now_func) + + class Plan(DeclarativeBase): """Plan is a recurring payment schedule, such as a hosting service plan. """ - __tablename__ = 'plan' guid = Column(String(64), primary_key=True) - #: 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(1024)) - #: 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 + # TODO: this is just a rough implementation, should allow + # a more flexiable setting later + frequency = Column(Integer, nullable=False) #: is this plan deleted? deleted = Column(Boolean, default=False, nullable=False) - #: the created datetime of this plan created_at = Column(DateTime(timezone=True), default=now_func) - #: the updated datetime of this plan updated_at = Column(DateTime(timezone=True), default=now_func) - - #: the fequency to bill user, 0=daily, 1=weekly, 2=monthly - # TODO: this is just a rough implementation, should allow - # a more flexiable setting later - frequency = Column(Integer, nullable=False) diff --git a/billy/tests/test_models/test_company.py b/billy/tests/test_models/test_company.py index 1215b99..9959bd6 100644 --- a/billy/tests/test_models/test_company.py +++ b/billy/tests/test_models/test_company.py @@ -1,49 +1,132 @@ from __future__ import unicode_literals -from datetime import datetime +import datetime -from billy.models import Company, ProcessorType -from billy.models.processor import DummyProcessor -from billy.tests import BaseTestCase, fixtures +import transaction +from freezegun import freeze_time +from billy.tests.helper import ModelTestCase -class CompanyTest(BaseTestCase): - def setUp(self): - super(CompanyTest, self).setUp() +@freeze_time('2013-08-16') +class TestCompanyModel(ModelTestCase): - 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! - ) + def make_one(self, *args, **kwargs): + from billy.models.company import CompanyModel + return CompanyModel(*args, **kwargs) - # 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()) + def test_get_company_by_guid(self): + model = self.make_one(self.session) + company = model.get_company_by_guid('CP_NON_EXIST') + self.assertEqual(company, None) - # Retrieving the instantiated processor class - processor_class = company.processor - self.assertIsInstance(processor_class, DummyProcessor) + with self.assertRaises(KeyError): + model.get_company_by_guid('CP_NON_EXIST', raise_error=True) - # Performing transactions, generally though these should be preferred - # by some application logic. - processor_class.check_balance('SOME_CUSTOMER_ID') + with transaction.manager: + guid = model.create_company(processor_key='my_secret_key') + model.delete_company(guid) + with self.assertRaises(KeyError): + model.get_company_by_guid(guid, raise_error=True) - # Since its a test company we can now delete it: - company.session.commit() - company.delete() + company = model.get_company_by_guid(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(company.guid, guid) + def test_create_company(self): + model = self.make_one(self.session) + name = 'awesome company' + processor_key = 'my_secret_key' + with transaction.manager: + guid = model.create_company( + name=name, + processor_key=processor_key, + ) + now = datetime.datetime.utcnow() + company = model.get_company_by_guid(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_update_company(self): + model = self.make_one(self.session) + with transaction.manager: + guid = model.create_company(processor_key='my_secret_key') + name = 'new name' + processor_key = 'new processor key' + api_key = 'new api key' + with transaction.manager: + model.update_company( + guid=guid, + name=name, + api_key=api_key, + processor_key=processor_key, + ) + company = model.get_company_by_guid(guid) + self.assertEqual(company.name, name) + self.assertEqual(company.processor_key, processor_key) + self.assertEqual(company.api_key, api_key) + + def test_update_company_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_company(processor_key='my_secret_key') + + company = model.get_company_by_guid(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_company(guid=guid) + updated_at = datetime.datetime.utcnow() + + company = model.get_company_by_guid(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_company(guid) + updated_at = datetime.datetime.utcnow() + + company = model.get_company_by_guid(guid) + self.assertEqual(company.updated_at, updated_at) + self.assertEqual(company.created_at, created_at) + + def test_update_company_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_company(processor_key='my_secret_key') + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update_company(guid, wrong_arg=True, neme='john') + + def test_delete_company(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_company(processor_key='my_secret_key') + model.delete_company(guid) + + company = model.get_company_by_guid(guid) + self.assertEqual(company, None) + + company = model.get_company_by_guid(guid, ignore_deleted=False) + self.assertEqual(company.deleted, True) diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 1994749..0f6022b 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -31,8 +31,6 @@ def test_get_plan(self): amount=99.99, frequency=model.FREQ_WEEKLY, ) - - with transaction.manager: model.delete_plan(guid) with self.assertRaises(KeyError): @@ -183,8 +181,6 @@ def test_delete_plan(self): amount=99.99, frequency=model.FREQ_WEEKLY, ) - - with transaction.manager: model.delete_plan(guid) plan = model.get_plan_by_guid(guid) From c309b7fce2adab17c67a9d2810c48dfed1e44a56 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 16 Aug 2013 23:27:11 +0800 Subject: [PATCH 028/158] Add relationship between plan and company --- billy/models/plan.py | 2 ++ billy/models/tables.py | 15 +++++++++++++++ billy/tests/test_models/test_plan.py | 16 ++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/billy/models/plan.py b/billy/models/plan.py index 53bce58..6eb2d23 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -53,6 +53,7 @@ def get_plan_by_guid(self, guid, raise_error=False, ignore_deleted=True): def create_plan( self, + company_guid, plan_type, amount, frequency, @@ -69,6 +70,7 @@ def create_plan( raise ValueError('Invalid frequency {}'.format(frequency)) plan = tables.Plan( guid='PL' + make_guid(), + company_guid=company_guid, plan_type=plan_type, amount=amount, frequency=frequency, diff --git a/billy/models/tables.py b/billy/models/tables.py index 66d7232..bf211bd 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -8,6 +8,8 @@ from sqlalchemy import Boolean from sqlalchemy import DateTime from sqlalchemy import Numeric +from sqlalchemy.schema import ForeignKey +from sqlalchemy.orm import relation from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import func @@ -61,6 +63,9 @@ class Company(DeclarativeBase): #: the updated datetime of this company updated_at = Column(DateTime(timezone=True), default=now_func) + #: plans of this company + plans = relation('Plan', cascade='all, delete-orphan', backref='company') + class Plan(DeclarativeBase): """Plan is a recurring payment schedule, such as a hosting service plan. @@ -69,6 +74,16 @@ class Plan(DeclarativeBase): __tablename__ = 'plan' guid = Column(String(64), primary_key=True) + #: the guid of company which owns this plan + company_guid = Column( + String(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 diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index 0f6022b..ef68e94 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -11,6 +11,14 @@ @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_company('my_secret_key') + def make_one(self, *args, **kwargs): from billy.models.plan import PlanModel return PlanModel(*args, **kwargs) @@ -26,6 +34,7 @@ def test_get_plan(self): with transaction.manager: guid = model.create_plan( + company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='name', amount=99.99, @@ -50,6 +59,7 @@ def test_create_plan(self): with transaction.manager: guid = model.create_plan( + company_guid=self.company_guid, plan_type=plan_type, name=name, amount=amount, @@ -63,6 +73,7 @@ def test_create_plan(self): plan = model.get_plan_by_guid(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) @@ -78,6 +89,7 @@ def test_create_plan_with_wrong_frequency(self): with self.assertRaises(ValueError): model.create_plan( + company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name=None, amount=999, @@ -89,6 +101,7 @@ def test_create_plan_with_wrong_type(self): with self.assertRaises(ValueError): model.create_plan( + company_guid=self.company_guid, plan_type=999, name=None, amount=999, @@ -100,6 +113,7 @@ def test_update_plan(self): with transaction.manager: guid = model.create_plan( + company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='old name', amount=99.99, @@ -131,6 +145,7 @@ def test_update_plan_updated_at(self): with transaction.manager: guid = model.create_plan( + company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='evil gangster charges protection fee from Tom weekly', amount=99.99, @@ -176,6 +191,7 @@ def test_delete_plan(self): with transaction.manager: guid = model.create_plan( + company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='name', amount=99.99, From d0343056a626dff59af09d49da58fb9b8e2d50d4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 17 Aug 2013 11:08:18 +0800 Subject: [PATCH 029/158] Add customer model --- billy/models/customer.py | 73 +++++++++++ billy/models/tables.py | 83 +++++++++++- billy/tests/test_models/test_customer.py | 154 +++++++++++++++++++++++ 3 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 billy/models/customer.py create mode 100644 billy/tests/test_models/test_customer.py diff --git a/billy/models/customer.py b/billy/models/customer.py new file mode 100644 index 0000000..4e1742e --- /dev/null +++ b/billy/models/customer.py @@ -0,0 +1,73 @@ +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_customer_by_guid(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_customer( + self, + company_guid, + payment_uri, + name=None, + external_id=None + ): + """Create a customer and return its id + + """ + customer = tables.Customer( + guid='CU' + make_guid(), + company_guid=company_guid, + payment_uri=payment_uri, + external_id=external_id, + name=name, + ) + self.session.add(customer) + self.session.flush() + return customer.guid + + def update_customer(self, guid, **kwargs): + """Update acustomer + + """ + customer = self.get_customer_by_guid(guid, raise_error=True) + now = tables.now_func() + customer.updated_at = now + for key in ['name', 'payment_uri', '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_customer(self, guid): + """Delete a customer + + """ + customer = self.get_customer_by_guid(guid, raise_error=True) + customer.deleted = True + self.session.add(customer) + self.session.flush() diff --git a/billy/models/tables.py b/billy/models/tables.py index bf211bd..1871164 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -2,12 +2,12 @@ from sqlalchemy import Column from sqlalchemy import Integer -from sqlalchemy import String from sqlalchemy import Unicode from sqlalchemy import UnicodeText from sqlalchemy import Boolean from sqlalchemy import DateTime from sqlalchemy import Numeric +from sqlalchemy import Float from sqlalchemy.schema import ForeignKey from sqlalchemy.orm import relation from sqlalchemy.ext.declarative import declarative_base @@ -49,9 +49,9 @@ class Company(DeclarativeBase): """ __tablename__ = 'company' - guid = Column(String(64), primary_key=True) + guid = Column(Unicode(64), primary_key=True) #: the API key for accessing billy system - api_key = Column(String(64), unique=True, index=True, nullable=False) + 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 @@ -67,16 +67,47 @@ class Company(DeclarativeBase): plans = relation('Plan', cascade='all, delete-orphan', backref='company') +class Customer(DeclarativeBase): + """A Customer is basically a user to billy system + + """ + __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 external ID given by user + external_id = Column(Unicode(128), index=True) + #: the payment URI associated with this customer + payment_uri = Column(Unicode(128), 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(timezone=True), default=now_func) + #: the updated datetime of this company + updated_at = Column(DateTime(timezone=True), default=now_func) + + class Plan(DeclarativeBase): """Plan is a recurring payment schedule, such as a hosting service plan. """ __tablename__ = 'plan' - guid = Column(String(64), primary_key=True) + guid = Column(Unicode(64), primary_key=True) #: the guid of company which owns this plan company_guid = Column( - String(64), + Unicode(64), ForeignKey( 'company.guid', ondelete='CASCADE', onupdate='CASCADE' @@ -106,3 +137,45 @@ class Plan(DeclarativeBase): created_at = Column(DateTime(timezone=True), default=now_func) #: the updated datetime of this plan updated_at = Column(DateTime(timezone=True), default=now_func) + + +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 discount of this subscription + # TODO: maybe we should use decimal here? what about accuracy issue? + discount = Column(Float) + #: 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 canceled datetime of this subscription + canceled_at = Column(DateTime(timezone=True), default=None) + #: the created datetime of this subscription + created_at = Column(DateTime(timezone=True), default=now_func) + #: the updated datetime of this subscription + updated_at = Column(DateTime(timezone=True), default=now_func) diff --git a/billy/tests/test_models/test_customer.py b/billy/tests/test_models/test_customer.py new file mode 100644 index 0000000..49faf42 --- /dev/null +++ b/billy/tests/test_models/test_customer.py @@ -0,0 +1,154 @@ +from __future__ import unicode_literals +import datetime + +import transaction +from freezegun import freeze_time + +from billy.tests.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_company('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_customer_by_guid('PL_NON_EXIST') + self.assertEqual(customer, None) + + with self.assertRaises(KeyError): + model.get_customer_by_guid('PL_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/id', + ) + model.delete_customer(guid) + + with self.assertRaises(KeyError): + model.get_customer_by_guid(guid, raise_error=True) + + customer = model.get_customer_by_guid(guid, ignore_deleted=False, raise_error=True) + self.assertEqual(customer.guid, guid) + + def test_create_customer(self): + model = self.make_one(self.session) + name = 'Tom' + payment_uri = '/v1/credit_card/id' + external_id = '5566_GOOD_BROTHERS' + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri=payment_uri, + name=name, + external_id=external_id, + ) + + now = datetime.datetime.utcnow() + + customer = model.get_customer_by_guid(guid) + self.assertEqual(customer.guid, guid) + self.assert_(customer.guid.startswith('CU')) + self.assertEqual(customer.company_guid, self.company_guid) + self.assertEqual(customer.name, name) + self.assertEqual(customer.payment_uri, payment_uri) + 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_update_customer(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/id', + external_id='old id', + name='old name', + ) + + customer = model.get_customer_by_guid(guid) + name = 'new name' + payment_uri = 'new payment uri' + external_id = 'new external id' + + with transaction.manager: + model.update_customer( + guid=guid, + payment_uri=payment_uri, + name=name, + external_id=external_id, + ) + + customer = model.get_customer_by_guid(guid) + self.assertEqual(customer.name, name) + self.assertEqual(customer.payment_uri, payment_uri) + self.assertEqual(customer.external_id, external_id) + + def test_update_customer_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/id', + ) + + customer = model.get_customer_by_guid(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_customer(guid=guid) + updated_at = datetime.datetime.utcnow() + + customer = model.get_customer_by_guid(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_customer(guid) + updated_at = datetime.datetime.utcnow() + + customer = model.get_customer_by_guid(guid) + self.assertEqual(customer.updated_at, updated_at) + self.assertEqual(customer.created_at, created_at) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update_customer(guid, wrong_arg=True, neme='john') + + def test_delete_customer(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/id', + ) + model.delete_customer(guid) + + customer = model.get_customer_by_guid(guid) + self.assertEqual(customer, None) + + customer = model.get_customer_by_guid(guid, ignore_deleted=False) + self.assertEqual(customer.deleted, True) From 2c94d9c1b976d994a2d98acccd7bb3aa85f7ca30 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 17 Aug 2013 12:03:19 +0800 Subject: [PATCH 030/158] Add subscription model and tests --- billy/models/customer.py | 2 +- billy/models/subscription.py | 91 ++++++++ billy/models/tables.py | 3 +- billy/tests/test_models/test_subscription.py | 213 +++++++++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 billy/models/subscription.py create mode 100644 billy/tests/test_models/test_subscription.py diff --git a/billy/models/customer.py b/billy/models/customer.py index 4e1742e..6f2471e 100644 --- a/billy/models/customer.py +++ b/billy/models/customer.py @@ -47,7 +47,7 @@ def create_customer( return customer.guid def update_customer(self, guid, **kwargs): - """Update acustomer + """Update a customer """ customer = self.get_customer_by_guid(guid, raise_error=True) diff --git a/billy/models/subscription.py b/billy/models/subscription.py new file mode 100644 index 0000000..c7ed20c --- /dev/null +++ b/billy/models/subscription.py @@ -0,0 +1,91 @@ +from __future__ import unicode_literals +import logging + +from billy.models import tables +from billy.utils.generic import make_guid + + +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_subscription_by_guid(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_subscription( + self, + customer_guid, + plan_guid, + external_id=None, + discount=None, + ): + """Create a subscription and return its id + + """ + if discount is not None and discount < 0: + raise ValueError('Discount should be a postive float number') + subscription = tables.Subscription( + guid='SU' + make_guid(), + customer_guid=customer_guid, + plan_guid=plan_guid, + discount=discount, + external_id=external_id, + ) + self.session.add(subscription) + self.session.flush() + return subscription.guid + + def update_subscription(self, guid, **kwargs): + """Update a subscription + + """ + subscription = self.get_subscription_by_guid(guid, raise_error=True) + now = tables.now_func() + subscription.updated_at = now + for key in ['discount', '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_subscription(self, guid, prorated_refund=False): + """Cancel a subscription + + :param prorated_refund: Should we generate a prorated refund + transaction according to remaining time of subscription period? + """ + subscription = self.get_subscription_by_guid(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 + if prorated_refund: + # TODO: handle prorated refund here + pass + self.session.add(subscription) + self.session.flush() diff --git a/billy/models/tables.py b/billy/models/tables.py index 1871164..73819a5 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -166,7 +166,8 @@ class Subscription(DeclarativeBase): index=True, nullable=False, ) - #: the discount of this subscription + #: the discount of this subscription, + # e.g. 0.3 means 30% price off disscount # TODO: maybe we should use decimal here? what about accuracy issue? discount = Column(Float) #: the external ID given by user diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py new file mode 100644 index 0000000..9dfebb8 --- /dev/null +++ b/billy/tests/test_models/test_subscription.py @@ -0,0 +1,213 @@ +from __future__ import unicode_literals +import datetime +import decimal + +import transaction +from freezegun import freeze_time + +from billy.tests.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 transaction.manager: + self.company_guid = self.company_model.create_company('my_secret_key') + self.daily_plan_guid = self.plan_model.create_plan( + 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_plan( + 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_plan( + 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_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/tom', + ) + + 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_subscription_by_guid('SU_NON_EXIST') + self.assertEqual(subscription, None) + + with self.assertRaises(KeyError): + model.get_subscription_by_guid('SU_NON_EXIST', raise_error=True) + + with transaction.manager: + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + subscription = model.get_subscription_by_guid(guid, raise_error=True) + self.assertEqual(subscription.guid, guid) + + def test_create_subscription(self): + model = self.make_one(self.session) + discount = 0.8 + external_id = '5566_GOOD_BROTHERS' + customer_guid = self.customer_tom_guid + plan_guid = self.monthly_plan_guid + + with transaction.manager: + guid = model.create_subscription( + customer_guid=customer_guid, + plan_guid=plan_guid, + discount=discount, + external_id=external_id, + ) + + now = datetime.datetime.utcnow() + + subscription = model.get_subscription_by_guid(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.discount, discount) + self.assertEqual(subscription.external_id, external_id) + self.assertEqual(subscription.canceled, False) + self.assertEqual(subscription.canceled_at, None) + self.assertEqual(subscription.created_at, now) + self.assertEqual(subscription.updated_at, now) + + def test_create_subscription_with_negtive_discount(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + with transaction.manager: + model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + discount=-0.1, + ) + + def test_update_subscription(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + discount=0.1, + external_id='old external id' + ) + + subscription = model.get_subscription_by_guid(guid) + discount = 0.3 + external_id = 'new external id' + + with transaction.manager: + model.update_subscription( + guid=guid, + discount=discount, + external_id=external_id, + ) + + subscription = model.get_subscription_by_guid(guid) + self.assertEqual(subscription.discount, discount) + self.assertEqual(subscription.external_id, external_id) + + def test_update_subscription_updated_at(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + + subscription = model.get_subscription_by_guid(guid) + created_at = subscription.created_at + + # advanced the current date time + with freeze_time('2013-08-16 07:00:01'): + with transaction.manager: + model.update_subscription(guid=guid) + updated_at = datetime.datetime.utcnow() + + subscription = model.get_subscription_by_guid(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 transaction.manager: + model.update_subscription(guid) + updated_at = datetime.datetime.utcnow() + + subscription = model.get_subscription_by_guid(guid) + self.assertEqual(subscription.canceled_at, None) + self.assertEqual(subscription.updated_at, updated_at) + self.assertEqual(subscription.created_at, created_at) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update_subscription(guid, wrong_arg=True, neme='john') + + def test_subscription_cancel(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.cancel_subscription(guid) + + now = datetime.datetime.utcnow() + + subscription = model.get_subscription_by_guid(guid) + self.assertEqual(subscription.canceled, True) + self.assertEqual(subscription.canceled_at, now) + + def test_subscription_cancel_with_prorated_refund(self): + model = self.make_one(self.session) + + with transaction.manager: + model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + # TODO: check prorated refund here + + def test_subscription_cancel_twice(self): + from billy.models.subscription import SubscriptionCanceledError + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + model.cancel_subscription(guid) + + with self.assertRaises(SubscriptionCanceledError): + model.cancel_subscription(guid) From 52e7c63f7ea6da51dcce72000f5ef98cd6d70750 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 12:36:47 +0800 Subject: [PATCH 031/158] Add transaction model and tests --- billy/models/__init__.py | 2 + billy/models/tables.py | 43 +++++ billy/models/transaction.py | 95 ++++++++++ billy/tests/test_models/test_subscription.py | 1 - billy/tests/test_models/test_transaction.py | 186 +++++++++++++++++++ 5 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 billy/models/transaction.py create mode 100644 billy/tests/test_models/test_transaction.py diff --git a/billy/models/__init__.py b/billy/models/__init__.py index 0fd41d8..a36886f 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from sqlalchemy import engine_from_config from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker diff --git a/billy/models/tables.py b/billy/models/tables.py index 73819a5..fcfae30 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -65,6 +65,8 @@ class Company(DeclarativeBase): #: plans of this company plans = relation('Plan', cascade='all, delete-orphan', backref='company') + #: customers of this company + customers = relation('Customer', cascade='all, delete-orphan', backref='company') class Customer(DeclarativeBase): @@ -138,6 +140,9 @@ class Plan(DeclarativeBase): #: the updated datetime of this plan updated_at = Column(DateTime(timezone=True), default=now_func) + #: subscriptions of this plan + subscriptions = relation('Subscription', cascade='all, delete-orphan', backref='plan') + class Subscription(DeclarativeBase): """A subscription relationship between Customer and Plan @@ -180,3 +185,41 @@ class Subscription(DeclarativeBase): created_at = Column(DateTime(timezone=True), default=now_func) #: the updated datetime of this subscription updated_at = Column(DateTime(timezone=True), default=now_func) + + #: transactions of this subscription + transactions = relation('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, + ) + #: what type of transaction it is, 0=charge, 1=refund, 2=payout + transaction_type = Column(Integer, index=True, nullable=False) + #: current status of this transaction, could be + # 0=init, 1=retrying, 2=done, 3=failed + # TODO: what about retry? + status = Column(Integer, index=True, nullable=False) + #: the amount to do transaction (charge, payout or refund) + amount = Column(Numeric(10, 2), index=True, nullable=False) + #: the payment URI + payment_uri = Column(Unicode(128), index=True, nullable=False) + #: the created datetime of this subscription + created_at = Column(DateTime(timezone=True), default=now_func) + #: the updated datetime of this subscription + updated_at = Column(DateTime(timezone=True), default=now_func) diff --git a/billy/models/transaction.py b/billy/models/transaction.py new file mode 100644 index 0000000..e3ab7fe --- /dev/null +++ b/billy/models/transaction.py @@ -0,0 +1,95 @@ +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 + + STATUS_ALL = [ + STATUS_INIT, + STATUS_RETRYING, + STATUS_DONE, + STATUS_FAILED, + ] + + def __init__(self, session, logger=None): + self.logger = logger or logging.getLogger(__name__) + self.session = session + + def get_transaction_by_guid(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 create_transaction( + self, + subscription_guid, + transaction_type, + amount, + payment_uri, + ): + """Create a transaction and return its ID + + """ + if transaction_type not in self.TYPE_ALL: + raise ValueError('Invalid transaction_type {}'.format(transaction_type)) + 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, + ) + self.session.add(transaction) + self.session.flush() + return transaction.guid + + def update_transaction(self, guid, **kwargs): + """Update a transaction + + """ + transaction = self.get_transaction_by_guid(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() diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 9dfebb8..e5e0a37 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals import datetime -import decimal import transaction from freezegun import freeze_time diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py new file mode 100644 index 0000000..10bc831 --- /dev/null +++ b/billy/tests/test_models/test_transaction.py @@ -0,0 +1,186 @@ +from __future__ import unicode_literals +import datetime + +import transaction as db_transaction +from freezegun import freeze_time + +from billy.tests.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_company('my_secret_key') + self.plan_guid = self.plan_model.create_plan( + 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_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/tester', + ) + self.subscription_guid = self.subscription_model.create_subscription( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + + 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_transaction_by_guid('TX_NON_EXIST') + self.assertEqual(transaction, None) + + with self.assertRaises(KeyError): + model.get_transaction_by_guid('TX_NON_EXIST', raise_error=True) + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + ) + + transaction = model.get_transaction_by_guid(guid, raise_error=True) + self.assertEqual(transaction.guid, guid) + + def test_create_transaction(self): + model = self.make_one(self.session) + + subscription_guid = self.subscription_guid + transaction_type = model.TYPE_CHARGE + amount = 100 + payment_uri = '/v1/credit_card/tester' + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=subscription_guid, + transaction_type=transaction_type, + amount=amount, + payment_uri=payment_uri, + ) + + now = datetime.datetime.utcnow() + + transaction = model.get_transaction_by_guid(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.created_at, now) + self.assertEqual(transaction.updated_at, now) + + def test_update_transaction(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + ) + + transaction = model.get_transaction_by_guid(guid) + status = model.STATUS_DONE + + with db_transaction.manager: + model.update_transaction( + guid=guid, + status=status, + ) + + transaction = model.get_transaction_by_guid(guid) + self.assertEqual(transaction.status, status) + + def test_update_transaction_updated_at(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + ) + + transaction = model.get_transaction_by_guid(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_transaction(guid=guid) + updated_at = datetime.datetime.utcnow() + + transaction = model.get_transaction_by_guid(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_transaction(guid) + updated_at = datetime.datetime.utcnow() + + transaction = model.get_transaction_by_guid(guid) + self.assertEqual(transaction.updated_at, updated_at) + self.assertEqual(transaction.created_at, created_at) + + def test_update_transaction_with_wrong_args(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + ) + + # make sure passing wrong argument will raise error + with self.assertRaises(TypeError): + model.update_transaction( + guid=guid, + wrong_arg=True, + status=model.STATUS_INIT + ) + + def test_update_transaction_with_wrong_status(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=10, + payment_uri='/v1/credit_card/tester', + ) + + with self.assertRaises(ValueError): + model.update_transaction( + guid=guid, + status=999, + ) From 1b006368d2b4aef6d8a5725c770de54ff2fa2dc5 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 12:38:57 +0800 Subject: [PATCH 032/158] Add missing line in transaction test --- billy/tests/test_models/test_transaction.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 10bc831..1d772d0 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -91,6 +91,17 @@ def test_create_transaction(self): self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) + def test_create_transaction_with_wrong_type(self): + model = self.make_one(self.session) + + with self.assertRaises(ValueError): + model.create_transaction( + subscription_guid=self.subscription_guid, + transaction_type=999, + amount=123, + payment_uri='/v1/credit_card/tester', + ) + def test_update_transaction(self): model = self.make_one(self.session) From e5ae84b5374e53cfc0a1edb5a714b332000cbca7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 12:46:26 +0800 Subject: [PATCH 033/158] Keep test cases for only one testing purpose --- billy/tests/test_models/test_customer.py | 9 +++++++++ billy/tests/test_models/test_plan.py | 12 ++++++++++++ billy/tests/test_models/test_subscription.py | 7 +++++++ 3 files changed, 28 insertions(+) diff --git a/billy/tests/test_models/test_customer.py b/billy/tests/test_models/test_customer.py index 49faf42..fa46c3e 100644 --- a/billy/tests/test_models/test_customer.py +++ b/billy/tests/test_models/test_customer.py @@ -133,6 +133,15 @@ def test_update_customer_updated_at(self): self.assertEqual(customer.updated_at, updated_at) self.assertEqual(customer.created_at, created_at) + def test_update_customer_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_customer( + company_guid=self.company_guid, + payment_uri='/v1/credit_card/id', + ) + # make sure passing wrong argument will raise error with self.assertRaises(TypeError): model.update_customer(guid, wrong_arg=True, neme='john') diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index ef68e94..a1ac4d0 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -182,6 +182,18 @@ def test_update_plan_updated_at(self): self.assertEqual(plan.updated_at, updated_at) self.assertEqual(plan.created_at, created_at) + def test_update_plan_with_wrong_args(self): + model = self.make_one(self.session) + + with transaction.manager: + guid = model.create_plan( + 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_plan(guid, wrong_arg=True, neme='john') diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index e5e0a37..c51be22 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -167,6 +167,13 @@ def test_update_subscription_updated_at(self): self.assertEqual(subscription.updated_at, updated_at) self.assertEqual(subscription.created_at, created_at) + def test_update_subscription_with_wrong_args(self): + model = self.make_one(self.session) + with transaction.manager: + guid = model.create_subscription( + 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_subscription(guid, wrong_arg=True, neme='john') From a25e47e6f34ab628cd98c1e7aa2fa88aea07c383 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 14:23:38 +0800 Subject: [PATCH 034/158] Ignore testing code from testing coverage --- .coveragerc | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .coveragerc 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/* From bd63086450d5254c6e4c0bd5cd04f7ab01b414d8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 18:56:00 +0800 Subject: [PATCH 035/158] Add started_at and scheduled_at fields to subscription and transaction model --- billy/models/subscription.py | 9 +++++++++ billy/models/tables.py | 6 ++++++ billy/models/transaction.py | 2 ++ billy/tests/test_models/test_subscription.py | 17 +++++++++++++++++ billy/tests/test_models/test_transaction.py | 12 ++++++++++-- 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index c7ed20c..20f29e2 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -2,6 +2,9 @@ import logging 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 @@ -35,6 +38,7 @@ def create_subscription( self, customer_guid, plan_guid, + started_at=None, external_id=None, discount=None, ): @@ -43,12 +47,17 @@ def create_subscription( """ if discount is not None and discount < 0: raise ValueError('Discount should be a postive float number') + if started_at is None: + started_at = tables.now_func() + # TODO: should we allow a past started_at value? subscription = tables.Subscription( guid='SU' + make_guid(), customer_guid=customer_guid, plan_guid=plan_guid, discount=discount, external_id=external_id, + started_at=started_at, + next_transaction_at=started_at, ) self.session.add(subscription) self.session.flush() diff --git a/billy/models/tables.py b/billy/models/tables.py index fcfae30..dccb7ad 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -179,6 +179,10 @@ class Subscription(DeclarativeBase): 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(timezone=True), nullable=False) + #: the started datetime of this subscription + started_at = Column(DateTime(timezone=True), nullable=False) #: the canceled datetime of this subscription canceled_at = Column(DateTime(timezone=True), default=None) #: the created datetime of this subscription @@ -219,6 +223,8 @@ class Transaction(DeclarativeBase): amount = Column(Numeric(10, 2), index=True, nullable=False) #: the payment URI payment_uri = Column(Unicode(128), index=True, nullable=False) + #: the scheduled datetime of this transaction should be processed + scheduled_at = Column(DateTime(timezone=True), default=now_func) #: the created datetime of this subscription created_at = Column(DateTime(timezone=True), default=now_func) #: the updated datetime of this subscription diff --git a/billy/models/transaction.py b/billy/models/transaction.py index e3ab7fe..51844ac 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -59,6 +59,7 @@ def create_transaction( transaction_type, amount, payment_uri, + scheduled_at, ): """Create a transaction and return its ID @@ -72,6 +73,7 @@ def create_transaction( amount=amount, payment_uri=payment_uri, status=self.STATUS_INIT, + scheduled_at=scheduled_at, ) self.session.add(transaction) self.session.flush() diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index c51be22..d28a777 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -95,6 +95,23 @@ def test_create_subscription(self): self.assertEqual(subscription.created_at, now) self.assertEqual(subscription.updated_at, now) + def test_create_subscription_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 transaction.manager: + guid = model.create_subscription( + customer_guid=customer_guid, + plan_guid=plan_guid, + started_at=started_at + ) + + subscription = model.get_subscription_by_guid(guid) + self.assertEqual(subscription.guid, guid) + self.assertEqual(subscription.started_at, started_at) + def test_create_subscription_with_negtive_discount(self): model = self.make_one(self.session) diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 1d772d0..ce3f3c5 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -57,6 +57,7 @@ def test_get_transaction(self): transaction_type=model.TYPE_CHARGE, amount=10, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) transaction = model.get_transaction_by_guid(guid, raise_error=True) @@ -69,6 +70,8 @@ def test_create_transaction(self): transaction_type = model.TYPE_CHARGE amount = 100 payment_uri = '/v1/credit_card/tester' + now = datetime.datetime.utcnow() + scheduled_at = now + datetime.timedelta(days=1) with db_transaction.manager: guid = model.create_transaction( @@ -76,10 +79,9 @@ def test_create_transaction(self): transaction_type=transaction_type, amount=amount, payment_uri=payment_uri, + scheduled_at=scheduled_at, ) - now = datetime.datetime.utcnow() - transaction = model.get_transaction_by_guid(guid) self.assertEqual(transaction.guid, guid) self.assert_(transaction.guid.startswith('TX')) @@ -88,6 +90,7 @@ def test_create_transaction(self): self.assertEqual(transaction.amount, amount) self.assertEqual(transaction.payment_uri, payment_uri) self.assertEqual(transaction.status, model.STATUS_INIT) + self.assertEqual(transaction.scheduled_at, scheduled_at) self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) @@ -100,6 +103,7 @@ def test_create_transaction_with_wrong_type(self): transaction_type=999, amount=123, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) def test_update_transaction(self): @@ -111,6 +115,7 @@ def test_update_transaction(self): transaction_type=model.TYPE_CHARGE, amount=10, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) transaction = model.get_transaction_by_guid(guid) @@ -134,6 +139,7 @@ def test_update_transaction_updated_at(self): transaction_type=model.TYPE_CHARGE, amount=10, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) transaction = model.get_transaction_by_guid(guid) @@ -169,6 +175,7 @@ def test_update_transaction_with_wrong_args(self): transaction_type=model.TYPE_CHARGE, amount=10, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) # make sure passing wrong argument will raise error @@ -188,6 +195,7 @@ def test_update_transaction_with_wrong_status(self): transaction_type=model.TYPE_CHARGE, amount=10, payment_uri='/v1/credit_card/tester', + scheduled_at=datetime.datetime.utcnow(), ) with self.assertRaises(ValueError): From f19a387651d8f741e125c3c0538d1435a91da03b Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 20:17:38 +0800 Subject: [PATCH 036/158] Add date time schedule model and tests --- billy/models/schedule.py | 30 +++++ billy/tests/test_models/test_schedule.py | 135 +++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 billy/models/schedule.py create mode 100644 billy/tests/test_models/test_schedule.py diff --git a/billy/models/schedule.py b/billy/models/schedule.py new file mode 100644 index 0000000..fa27e99 --- /dev/null +++ b/billy/models/schedule.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals + +from dateutil.relativedelta import relativedelta + +from billy.models.plan import PlanModel + + +def next_transaction_datetime(started_at, frequency, period): + """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 + very first transaction + """ + if frequency not in PlanModel.FREQ_ALL: + raise ValueError('Invalid frequency {}'.format(frequency)) + if period == 0: + return started_at + delta = None + if frequency == PlanModel.FREQ_DAILY: + delta = relativedelta(days=period) + elif frequency == PlanModel.FREQ_WEEKLY: + delta = relativedelta(weeks=period) + elif frequency == PlanModel.FREQ_MONTHLY: + delta = relativedelta(months=period) + elif frequency == PlanModel.FREQ_YEARLY: + delta = relativedelta(years=period) + return started_at + delta diff --git a/billy/tests/test_models/test_schedule.py b/billy/tests/test_models/test_schedule.py new file mode 100644 index 0000000..0405285 --- /dev/null +++ b/billy/tests/test_models/test_schedule.py @@ -0,0 +1,135 @@ +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, length, expected): + from billy.models.schedule import next_transaction_datetime + result = [] + for period in range(length): + dt = next_transaction_datetime(started_at, frequency, period) + result.append(dt) + self.assertEqual(result, expected) + + def test_daily_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-07-28'): + now = datetime.datetime.utcnow() + self.assert_schedule(now, PlanModel.FREQ_DAILY, 10, [ + 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_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(now, PlanModel.FREQ_WEEKLY, 5, [ + 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_monthly_schedule(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-18'): + now = datetime.datetime.utcnow() + self.assert_schedule(now, PlanModel.FREQ_MONTHLY, 6, [ + 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_end_of_month(self): + from billy.models.plan import PlanModel + with freeze_time('2013-08-31'): + now = datetime.datetime.utcnow() + self.assert_schedule(now, PlanModel.FREQ_MONTHLY, 7, [ + 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(now, PlanModel.FREQ_MONTHLY, 6, [ + 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(now, PlanModel.FREQ_YEARLY, 5, [ + 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_leap_year(self): + from billy.models.plan import PlanModel + with freeze_time('2012-02-29'): + now = datetime.datetime.utcnow() + self.assert_schedule(now, PlanModel.FREQ_YEARLY, 5, [ + 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), + ]) From bd71802b633898bfc602d97a9d82ca5ce5b26ce7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 20:33:25 +0800 Subject: [PATCH 037/158] Add yield_transactions for subscription model --- billy/models/subscription.py | 59 ++++++++++++++++++ billy/models/tables.py | 5 ++ billy/tests/test_models/test_subscription.py | 63 +++++++++++++++----- 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 20f29e2..a4b0fc7 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -98,3 +98,62 @@ def cancel_subscription(self, guid, prorated_refund=False): pass self.session.add(subscription) self.session.flush() + + def yield_transactions(self, now=None): + """Generate new necessary transactions according to subscriptions we + had return guid list + + :param now: the current date time to use, now_func() will be used by + default + :return: 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)) + + for subscription in query: + 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)) + # create the new transaction for this subscription + guid = tx_model.create_transaction( + subscription_guid=subscription.guid, + payment_uri=subscription.customer.payment_uri, + amount=subscription.plan.amount, + transaction_type=transaction_type, + scheduled_at=subscription.next_transaction_at, + ) + # 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, + ) + self.session.add(subscription) + transaction_guids.append(guid) + # okay, we have no more transaction to process, just break + else: + break + + self.session.flush() + return transaction_guids diff --git a/billy/models/tables.py b/billy/models/tables.py index dccb7ad..1e3b6f9 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -99,6 +99,9 @@ class Customer(DeclarativeBase): #: the updated datetime of this company updated_at = Column(DateTime(timezone=True), default=now_func) + #: subscriptions of this customer + subscriptions = relation('Subscription', cascade='all, delete-orphan', backref='customer') + class Plan(DeclarativeBase): """Plan is a recurring payment schedule, such as a hosting service plan. @@ -181,6 +184,8 @@ class Subscription(DeclarativeBase): canceled = Column(Boolean, default=False, nullable=False) #: the next datetime to charge or pay out next_transaction_at = Column(DateTime(timezone=True), 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(timezone=True), nullable=False) #: the canceled datetime of this subscription diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index d28a777..db2672d 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import datetime -import transaction +import transaction as db_transaction from freezegun import freeze_time from billy.tests.helper import ModelTestCase @@ -19,7 +19,7 @@ def setUp(self): self.company_model = CompanyModel(self.session) self.customer_model = CustomerModel(self.session) self.plan_model = PlanModel(self.session) - with transaction.manager: + with db_transaction.manager: self.company_guid = self.company_model.create_company('my_secret_key') self.daily_plan_guid = self.plan_model.create_plan( company_guid=self.company_guid, @@ -57,7 +57,7 @@ def test_get_subscription(self): with self.assertRaises(KeyError): model.get_subscription_by_guid('SU_NON_EXIST', raise_error=True) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -73,7 +73,7 @@ def test_create_subscription(self): customer_guid = self.customer_tom_guid plan_guid = self.monthly_plan_guid - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=customer_guid, plan_guid=plan_guid, @@ -90,8 +90,11 @@ def test_create_subscription(self): self.assertEqual(subscription.plan_guid, plan_guid) self.assertEqual(subscription.discount, discount) self.assertEqual(subscription.external_id, external_id) + 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) @@ -101,7 +104,7 @@ def test_create_subscription_with_started_at(self): plan_guid = self.monthly_plan_guid started_at = datetime.datetime.utcnow() + datetime.timedelta(days=1) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=customer_guid, plan_guid=plan_guid, @@ -116,7 +119,7 @@ def test_create_subscription_with_negtive_discount(self): model = self.make_one(self.session) with self.assertRaises(ValueError): - with transaction.manager: + with db_transaction.manager: model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -126,7 +129,7 @@ def test_create_subscription_with_negtive_discount(self): def test_update_subscription(self): model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -138,7 +141,7 @@ def test_update_subscription(self): discount = 0.3 external_id = 'new external id' - with transaction.manager: + with db_transaction.manager: model.update_subscription( guid=guid, discount=discount, @@ -152,7 +155,7 @@ def test_update_subscription(self): def test_update_subscription_updated_at(self): model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -163,7 +166,7 @@ def test_update_subscription_updated_at(self): # advanced the current date time with freeze_time('2013-08-16 07:00:01'): - with transaction.manager: + with db_transaction.manager: model.update_subscription(guid=guid) updated_at = datetime.datetime.utcnow() @@ -175,7 +178,7 @@ def test_update_subscription_updated_at(self): # 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: + with db_transaction.manager: model.update_subscription(guid) updated_at = datetime.datetime.utcnow() @@ -186,7 +189,7 @@ def test_update_subscription_updated_at(self): def test_update_subscription_with_wrong_args(self): model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -198,7 +201,7 @@ def test_update_subscription_with_wrong_args(self): def test_subscription_cancel(self): model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -214,7 +217,7 @@ def test_subscription_cancel(self): def test_subscription_cancel_with_prorated_refund(self): model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -225,7 +228,7 @@ def test_subscription_cancel_twice(self): from billy.models.subscription import SubscriptionCanceledError model = self.make_one(self.session) - with transaction.manager: + with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, @@ -234,3 +237,33 @@ def test_subscription_cancel_twice(self): with self.assertRaises(SubscriptionCanceledError): model.cancel_subscription(guid) + + def test_yield_transactions(self): + from billy.models.transaction import TransactionModel + + now = datetime.datetime.utcnow() + + model = self.make_one(self.session) + with db_transaction.manager: + guid = model.create_subscription( + 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_subscription_by_guid(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) From 718bf399aac789efc601c171fa211ecc452b345c Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 22:01:09 +0800 Subject: [PATCH 038/158] Add missing dateutil in requirments --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0e52923..af481ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ SQLAlchemy==0.8.2 Zope.SQLAlchemy==0.7.2 -nose==1.3.0 \ No newline at end of file +nose==1.3.0 +python-dateutil==1.5 \ No newline at end of file From d9107323507b0a9fda66833bba5f1e34b86b41a4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 22:50:07 +0800 Subject: [PATCH 039/158] Update tests for yield_transactions --- billy/models/subscription.py | 10 +-- billy/tests/test_models/test_subscription.py | 66 +++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index a4b0fc7..4c4f209 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -122,11 +122,12 @@ def yield_transactions(self, now=None): # 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) \ + subscriptions = self.session.query(Subscription) \ .filter(Subscription.next_transaction_at <= now) \ - .filter(not_(Subscription.canceled)) + .filter(not_(Subscription.canceled)) \ + .all() - for subscription in query: + 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: @@ -150,9 +151,10 @@ def yield_transactions(self, now=None): period=subscription.period, ) self.session.add(subscription) + self.session.flush() transaction_guids.append(guid) # okay, we have no more transaction to process, just break - else: + if not subscriptions: break self.session.flush() diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index db2672d..c0ddb79 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -241,9 +241,11 @@ def test_subscription_cancel_twice(self): 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() - model = self.make_one(self.session) with db_transaction.manager: guid = model.create_subscription( customer_guid=self.customer_tom_guid, @@ -267,3 +269,65 @@ def test_yield_transactions(self): 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_subscription_by_guid(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_subscription_by_guid(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_subscription_by_guid(guid) + self.assertEqual(len(subscription.transactions), 2) + + transaction = tx_model.get_transaction_by_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, 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_with_multiple_period(self): + model = self.make_one(self.session) + + with db_transaction.manager: + guid = model.create_subscription( + 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_subscription_by_guid(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)) + + tx_dates = [tx.scheduled_at for tx in subscription.transactions] + self.assertEqual(tx_dates, [ + datetime.datetime(2013, 8, 16), + datetime.datetime(2013, 9, 16), + datetime.datetime(2013, 10, 16), + ]) From ebb69aa708fa4fd56e4ffc5844e13d72a1eedacc Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 22:50:25 +0800 Subject: [PATCH 040/158] Add missing __init__.py for test_utils package --- billy/tests/test_utils/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 billy/tests/test_utils/__init__.py diff --git a/billy/tests/test_utils/__init__.py b/billy/tests/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 From 7e0fe024e8566e0ce8171d837a618d2a3ee9e3b1 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 23:05:33 +0800 Subject: [PATCH 041/158] Add more tests for yield_transactions --- billy/tests/test_models/test_subscription.py | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index c0ddb79..5c40605 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -331,3 +331,70 @@ def test_yield_transactions_with_multiple_period(self): datetime.datetime(2013, 9, 16), datetime.datetime(2013, 10, 16), ]) + + 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_plan( + company_guid=self.company_guid, + plan_type=self.plan_model.TYPE_PAYOUT, + amount=10, + frequency=self.plan_model.FREQ_MONTHLY, + ) + guid = model.create_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=plan_guid, + ) + model.yield_transactions() + + subscription = model.get_subscription_by_guid(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_subscription( + 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_subscription_by_guid(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_subscription_by_guid(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_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + ) + subscription = model.get_subscription_by_guid(guid) + subscription.plan.plan_type = 999 + self.session.add(subscription.plan) + + with self.assertRaises(ValueError): + model.yield_transactions() From 38a7e7660e7b3029fa2b7436fd8f5bbfb9f9dc32 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 23:14:18 +0800 Subject: [PATCH 042/158] Improve test coverage for schedule model --- billy/tests/test_models/test_schedule.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/billy/tests/test_models/test_schedule.py b/billy/tests/test_models/test_schedule.py index 0405285..f4b338e 100644 --- a/billy/tests/test_models/test_schedule.py +++ b/billy/tests/test_models/test_schedule.py @@ -16,6 +16,11 @@ def assert_schedule(self, started_at, frequency, length, expected): 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(datetime.datetime.utcnow(), 999, 0) + def test_daily_schedule(self): from billy.models.plan import PlanModel with freeze_time('2013-07-28'): From 60423c65aabb7dcc9ef828c668d654c1d3a18420 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 18 Aug 2013 23:24:02 +0800 Subject: [PATCH 043/158] Add more tests for yield_transactions --- billy/models/subscription.py | 7 ++++--- billy/tests/test_models/test_subscription.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 4c4f209..ee9217e 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -127,6 +127,10 @@ def yield_transactions(self, now=None): .filter(not_(Subscription.canceled)) \ .all() + # okay, we have no more transaction to process, just break + if not subscriptions: + break + for subscription in subscriptions: if subscription.plan.plan_type == PlanModel.TYPE_CHARGE: transaction_type = tx_model.TYPE_CHARGE @@ -153,9 +157,6 @@ def yield_transactions(self, now=None): self.session.add(subscription) self.session.flush() transaction_guids.append(guid) - # okay, we have no more transaction to process, just break - if not subscriptions: - break self.session.flush() return transaction_guids diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 5c40605..9bcbf86 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -398,3 +398,19 @@ def test_yield_transactions_with_wrong_type(self): 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_subscription( + customer_guid=self.customer_tom_guid, + plan_guid=self.monthly_plan_guid, + started_at=datetime.datetime(2013, 9, 1), + ) + model.cancel_subscription(guid) + + tx_guids = model.yield_transactions() + self.assertFalse(tx_guids) + subscription = model.get_subscription_by_guid(guid) + self.assertFalse(subscription.transactions) From e9e074aef7a7ade78d2e5629ec4d68416b981ecb Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 09:33:08 +0800 Subject: [PATCH 044/158] Refactory model naming --- billy/models/company.py | 12 +- billy/models/customer.py | 12 +- billy/models/plan.py | 12 +- billy/models/subscription.py | 14 +-- billy/models/transaction.py | 8 +- billy/tests/test_models/test_company.py | 58 ++++----- billy/tests/test_models/test_customer.py | 60 +++++----- billy/tests/test_models/test_plan.py | 68 +++++------ billy/tests/test_models/test_subscription.py | 118 ++++++++++--------- billy/tests/test_models/test_transaction.py | 62 +++++----- 10 files changed, 213 insertions(+), 211 deletions(-) diff --git a/billy/models/company.py b/billy/models/company.py index 1899d09..2a8a501 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -12,7 +12,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_company_by_guid(self, guid, raise_error=False, ignore_deleted=True): + def get(self, guid, raise_error=False, ignore_deleted=True): """Find a company by guid and return it :param guid: The guild of company to get @@ -26,7 +26,7 @@ def get_company_by_guid(self, guid, raise_error=False, ignore_deleted=True): raise KeyError('No such company {}'.format(guid)) return query - def create_company(self, processor_key, name=None): + def create(self, processor_key, name=None): """Create a company and return its id """ @@ -40,11 +40,11 @@ def create_company(self, processor_key, name=None): self.session.flush() return company.guid - def update_company(self, guid, **kwargs): + def update(self, guid, **kwargs): """Update a company """ - company = self.get_company_by_guid(guid, raise_error=True) + company = self.get(guid, raise_error=True) now = tables.now_func() company.updated_at = now for key in ['name', 'processor_key', 'api_key']: @@ -57,11 +57,11 @@ def update_company(self, guid, **kwargs): self.session.add(company) self.session.flush() - def delete_company(self, guid): + def delete(self, guid): """Delete a company """ - company = self.get_company_by_guid(guid, raise_error=True) + company = self.get(guid, raise_error=True) company.deleted = True self.session.add(company) self.session.flush() diff --git a/billy/models/customer.py b/billy/models/customer.py index 6f2471e..ba447a9 100644 --- a/billy/models/customer.py +++ b/billy/models/customer.py @@ -11,7 +11,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_customer_by_guid(self, guid, raise_error=False, ignore_deleted=True): + 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 @@ -25,7 +25,7 @@ def get_customer_by_guid(self, guid, raise_error=False, ignore_deleted=True): raise KeyError('No such customer {}'.format(guid)) return query - def create_customer( + def create( self, company_guid, payment_uri, @@ -46,11 +46,11 @@ def create_customer( self.session.flush() return customer.guid - def update_customer(self, guid, **kwargs): + def update(self, guid, **kwargs): """Update a customer """ - customer = self.get_customer_by_guid(guid, raise_error=True) + customer = self.get(guid, raise_error=True) now = tables.now_func() customer.updated_at = now for key in ['name', 'payment_uri', 'external_id']: @@ -63,11 +63,11 @@ def update_customer(self, guid, **kwargs): self.session.add(customer) self.session.flush() - def delete_customer(self, guid): + def delete(self, guid): """Delete a customer """ - customer = self.get_customer_by_guid(guid, raise_error=True) + customer = self.get(guid, raise_error=True) customer.deleted = True self.session.add(customer) self.session.flush() diff --git a/billy/models/plan.py b/billy/models/plan.py index 6eb2d23..6c6b116 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -37,7 +37,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_plan_by_guid(self, guid, raise_error=False, ignore_deleted=True): + 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 @@ -51,7 +51,7 @@ def get_plan_by_guid(self, guid, raise_error=False, ignore_deleted=True): raise KeyError('No such plan {}'.format(guid)) return query - def create_plan( + def create( self, company_guid, plan_type, @@ -82,11 +82,11 @@ def create_plan( self.session.flush() return plan.guid - def update_plan(self, guid, **kwargs): + def update(self, guid, **kwargs): """Update a plan """ - plan = self.get_plan_by_guid(guid, raise_error=True) + plan = self.get(guid, raise_error=True) now = tables.now_func() plan.updated_at = now for key in ['name', 'external_id', 'description']: @@ -99,11 +99,11 @@ def update_plan(self, guid, **kwargs): self.session.add(plan) self.session.flush() - def delete_plan(self, guid): + def delete(self, guid): """Delete a plan """ - plan = self.get_plan_by_guid(guid, raise_error=True) + plan = self.get(guid, raise_error=True) plan.deleted = True self.session.add(plan) self.session.flush() diff --git a/billy/models/subscription.py b/billy/models/subscription.py index ee9217e..b134795 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -21,7 +21,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_subscription_by_guid(self, guid, raise_error=False): + def get(self, guid, raise_error=False): """Find a subscription by guid and return it :param guid: The guild of subscription to get @@ -34,7 +34,7 @@ def get_subscription_by_guid(self, guid, raise_error=False): raise KeyError('No such subscription {}'.format(guid)) return query - def create_subscription( + def create( self, customer_guid, plan_guid, @@ -63,11 +63,11 @@ def create_subscription( self.session.flush() return subscription.guid - def update_subscription(self, guid, **kwargs): + def update(self, guid, **kwargs): """Update a subscription """ - subscription = self.get_subscription_by_guid(guid, raise_error=True) + subscription = self.get(guid, raise_error=True) now = tables.now_func() subscription.updated_at = now for key in ['discount', 'external_id']: @@ -80,13 +80,13 @@ def update_subscription(self, guid, **kwargs): self.session.add(subscription) self.session.flush() - def cancel_subscription(self, guid, prorated_refund=False): + def cancel(self, guid, prorated_refund=False): """Cancel a subscription :param prorated_refund: Should we generate a prorated refund transaction according to remaining time of subscription period? """ - subscription = self.get_subscription_by_guid(guid, raise_error=True) + subscription = self.get(guid, raise_error=True) if subscription.canceled: raise SubscriptionCanceledError('Subscription {} is already ' 'canceled'.format(guid)) @@ -140,7 +140,7 @@ def yield_transactions(self, now=None): raise ValueError('Unknown plan type {} to process' .format(subscription.plan.plan_type)) # create the new transaction for this subscription - guid = tx_model.create_transaction( + guid = tx_model.create( subscription_guid=subscription.guid, payment_uri=subscription.customer.payment_uri, amount=subscription.plan.amount, diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 51844ac..2e65abb 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -40,7 +40,7 @@ def __init__(self, session, logger=None): self.logger = logger or logging.getLogger(__name__) self.session = session - def get_transaction_by_guid(self, guid, raise_error=False): + def get(self, guid, raise_error=False): """Find a transaction by guid and return it :param guid: The guild of transaction to get @@ -53,7 +53,7 @@ def get_transaction_by_guid(self, guid, raise_error=False): raise KeyError('No such transaction {}'.format(guid)) return query - def create_transaction( + def create( self, subscription_guid, transaction_type, @@ -79,11 +79,11 @@ def create_transaction( self.session.flush() return transaction.guid - def update_transaction(self, guid, **kwargs): + def update(self, guid, **kwargs): """Update a transaction """ - transaction = self.get_transaction_by_guid(guid, raise_error=True) + transaction = self.get(guid, raise_error=True) now = tables.now_func() transaction.updated_at = now if 'status' in kwargs: diff --git a/billy/tests/test_models/test_company.py b/billy/tests/test_models/test_company.py index 9959bd6..6e55ed9 100644 --- a/billy/tests/test_models/test_company.py +++ b/billy/tests/test_models/test_company.py @@ -14,39 +14,39 @@ def make_one(self, *args, **kwargs): from billy.models.company import CompanyModel return CompanyModel(*args, **kwargs) - def test_get_company_by_guid(self): + def test_get(self): model = self.make_one(self.session) - company = model.get_company_by_guid('CP_NON_EXIST') + company = model.get('CP_NON_EXIST') self.assertEqual(company, None) with self.assertRaises(KeyError): - model.get_company_by_guid('CP_NON_EXIST', raise_error=True) + model.get('CP_NON_EXIST', raise_error=True) with transaction.manager: - guid = model.create_company(processor_key='my_secret_key') - model.delete_company(guid) + guid = model.create(processor_key='my_secret_key') + model.delete(guid) with self.assertRaises(KeyError): - model.get_company_by_guid(guid, raise_error=True) + model.get(guid, raise_error=True) - company = model.get_company_by_guid(guid, ignore_deleted=False, raise_error=True) + company = model.get(guid, ignore_deleted=False, raise_error=True) self.assertEqual(company.guid, guid) - def test_create_company(self): + def test_create(self): model = self.make_one(self.session) name = 'awesome company' processor_key = 'my_secret_key' with transaction.manager: - guid = model.create_company( + guid = model.create( name=name, processor_key=processor_key, ) now = datetime.datetime.utcnow() - company = model.get_company_by_guid(guid) + company = model.get(guid) self.assertEqual(company.guid, guid) self.assert_(company.guid.startswith('CP')) self.assertEqual(company.name, name) @@ -56,77 +56,77 @@ def test_create_company(self): self.assertEqual(company.created_at, now) self.assertEqual(company.updated_at, now) - def test_update_company(self): + def test_update(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_company(processor_key='my_secret_key') + 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_company( + model.update( guid=guid, name=name, api_key=api_key, processor_key=processor_key, ) - company = model.get_company_by_guid(guid) + 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_company_updated_at(self): + def test_update_updated_at(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_company(processor_key='my_secret_key') + guid = model.create(processor_key='my_secret_key') - company = model.get_company_by_guid(guid) + 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_company(guid=guid) + model.update(guid=guid) updated_at = datetime.datetime.utcnow() - company = model.get_company_by_guid(guid) + 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_company(guid) + model.update(guid) updated_at = datetime.datetime.utcnow() - company = model.get_company_by_guid(guid) + company = model.get(guid) self.assertEqual(company.updated_at, updated_at) self.assertEqual(company.created_at, created_at) - def test_update_company_with_wrong_args(self): + def test_update_with_wrong_args(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_company(processor_key='my_secret_key') + guid = model.create(processor_key='my_secret_key') # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_company(guid, wrong_arg=True, neme='john') + model.update(guid, wrong_arg=True, neme='john') - def test_delete_company(self): + def test_delete(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_company(processor_key='my_secret_key') - model.delete_company(guid) + guid = model.create(processor_key='my_secret_key') + model.delete(guid) - company = model.get_company_by_guid(guid) + company = model.get(guid) self.assertEqual(company, None) - company = model.get_company_by_guid(guid, ignore_deleted=False) + company = model.get(guid, ignore_deleted=False) self.assertEqual(company.deleted, True) diff --git a/billy/tests/test_models/test_customer.py b/billy/tests/test_models/test_customer.py index fa46c3e..1d01993 100644 --- a/billy/tests/test_models/test_customer.py +++ b/billy/tests/test_models/test_customer.py @@ -16,7 +16,7 @@ def setUp(self): # build the basic scenario for plan model self.company_model = CompanyModel(self.session) with transaction.manager: - self.company_guid = self.company_model.create_company('my_secret_key') + self.company_guid = self.company_model.create('my_secret_key') def make_one(self, *args, **kwargs): from billy.models.customer import CustomerModel @@ -25,33 +25,33 @@ def make_one(self, *args, **kwargs): def test_get_customer(self): model = self.make_one(self.session) - customer = model.get_customer_by_guid('PL_NON_EXIST') + customer = model.get('PL_NON_EXIST') self.assertEqual(customer, None) with self.assertRaises(KeyError): - model.get_customer_by_guid('PL_NON_EXIST', raise_error=True) + model.get('PL_NON_EXIST', raise_error=True) with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/id', ) - model.delete_customer(guid) + model.delete(guid) with self.assertRaises(KeyError): - model.get_customer_by_guid(guid, raise_error=True) + model.get(guid, raise_error=True) - customer = model.get_customer_by_guid(guid, ignore_deleted=False, raise_error=True) + customer = model.get(guid, ignore_deleted=False, raise_error=True) self.assertEqual(customer.guid, guid) - def test_create_customer(self): + def test_create(self): model = self.make_one(self.session) name = 'Tom' payment_uri = '/v1/credit_card/id' external_id = '5566_GOOD_BROTHERS' with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri=payment_uri, name=name, @@ -60,7 +60,7 @@ def test_create_customer(self): now = datetime.datetime.utcnow() - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) self.assertEqual(customer.guid, guid) self.assert_(customer.guid.startswith('CU')) self.assertEqual(customer.company_guid, self.company_guid) @@ -71,54 +71,54 @@ def test_create_customer(self): self.assertEqual(customer.created_at, now) self.assertEqual(customer.updated_at, now) - def test_update_customer(self): + def test_update(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/id', external_id='old id', name='old name', ) - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) name = 'new name' payment_uri = 'new payment uri' external_id = 'new external id' with transaction.manager: - model.update_customer( + model.update( guid=guid, payment_uri=payment_uri, name=name, external_id=external_id, ) - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) self.assertEqual(customer.name, name) self.assertEqual(customer.payment_uri, payment_uri) self.assertEqual(customer.external_id, external_id) - def test_update_customer_updated_at(self): + def test_update_updated_at(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/id', ) - customer = model.get_customer_by_guid(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_customer(guid=guid) + model.update(guid=guid) updated_at = datetime.datetime.utcnow() - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) self.assertEqual(customer.updated_at, updated_at) self.assertEqual(customer.created_at, created_at) @@ -126,38 +126,38 @@ def test_update_customer_updated_at(self): with freeze_time('2013-08-16 08:35:40'): # this should update the updated_at field only with transaction.manager: - model.update_customer(guid) + model.update(guid) updated_at = datetime.datetime.utcnow() - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) self.assertEqual(customer.updated_at, updated_at) self.assertEqual(customer.created_at, created_at) - def test_update_customer_with_wrong_args(self): + def test_update_with_wrong_args(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/id', ) # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_customer(guid, wrong_arg=True, neme='john') + model.update(guid, wrong_arg=True, neme='john') - def test_delete_customer(self): + def test_delete(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_customer( + guid = model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/id', ) - model.delete_customer(guid) + model.delete(guid) - customer = model.get_customer_by_guid(guid) + customer = model.get(guid) self.assertEqual(customer, None) - customer = model.get_customer_by_guid(guid, ignore_deleted=False) + customer = model.get(guid, ignore_deleted=False) self.assertEqual(customer.deleted, True) diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index a1ac4d0..a8e414b 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -17,7 +17,7 @@ def setUp(self): # build the basic scenario for plan model self.company_model = CompanyModel(self.session) with transaction.manager: - self.company_guid = self.company_model.create_company('my_secret_key') + self.company_guid = self.company_model.create('my_secret_key') def make_one(self, *args, **kwargs): from billy.models.plan import PlanModel @@ -26,29 +26,29 @@ def make_one(self, *args, **kwargs): def test_get_plan(self): model = self.make_one(self.session) - plan = model.get_plan_by_guid('PL_NON_EXIST') + plan = model.get('PL_NON_EXIST') self.assertEqual(plan, None) with self.assertRaises(KeyError): - model.get_plan_by_guid('PL_NON_EXIST', raise_error=True) + model.get('PL_NON_EXIST', raise_error=True) with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='name', amount=99.99, frequency=model.FREQ_WEEKLY, ) - model.delete_plan(guid) + model.delete(guid) with self.assertRaises(KeyError): - model.get_plan_by_guid(guid, raise_error=True) + model.get(guid, raise_error=True) - plan = model.get_plan_by_guid(guid, ignore_deleted=False, raise_error=True) + plan = model.get(guid, ignore_deleted=False, raise_error=True) self.assertEqual(plan.guid, guid) - def test_create_plan(self): + def test_create(self): model = self.make_one(self.session) name = 'monthly billing to user John' amount = decimal.Decimal('5566.77') @@ -58,7 +58,7 @@ def test_create_plan(self): description = 'This is a long description' with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=plan_type, name=name, @@ -70,7 +70,7 @@ def test_create_plan(self): now = datetime.datetime.utcnow() - plan = model.get_plan_by_guid(guid) + plan = model.get(guid) self.assertEqual(plan.guid, guid) self.assert_(plan.guid.startswith('PL')) self.assertEqual(plan.company_guid, self.company_guid) @@ -84,11 +84,11 @@ def test_create_plan(self): self.assertEqual(plan.created_at, now) self.assertEqual(plan.updated_at, now) - def test_create_plan_with_wrong_frequency(self): + def test_create_with_wrong_frequency(self): model = self.make_one(self.session) with self.assertRaises(ValueError): - model.create_plan( + model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name=None, @@ -96,11 +96,11 @@ def test_create_plan_with_wrong_frequency(self): frequency=999, ) - def test_create_plan_with_wrong_type(self): + def test_create_with_wrong_type(self): model = self.make_one(self.session) with self.assertRaises(ValueError): - model.create_plan( + model.create( company_guid=self.company_guid, plan_type=999, name=None, @@ -108,11 +108,11 @@ def test_create_plan_with_wrong_type(self): frequency=model.FREQ_DAILY, ) - def test_update_plan(self): + def test_update(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='old name', @@ -122,29 +122,29 @@ def test_update_plan(self): external_id='old external id', ) - plan = model.get_plan_by_guid(guid) + plan = model.get(guid) name = 'new name' description = 'new description' external_id = 'new external id' with transaction.manager: - model.update_plan( + model.update( guid=guid, name=name, description=description, external_id=external_id, ) - plan = model.get_plan_by_guid(guid) + plan = model.get(guid) self.assertEqual(plan.name, name) self.assertEqual(plan.description, description) self.assertEqual(plan.external_id, external_id) - def test_update_plan_updated_at(self): + def test_update_updated_at(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='evil gangster charges protection fee from Tom weekly', @@ -152,20 +152,20 @@ def test_update_plan_updated_at(self): frequency=model.FREQ_WEEKLY, ) - plan = model.get_plan_by_guid(guid) + 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_plan( + model.update( guid=guid, name=name, ) updated_at = datetime.datetime.utcnow() - plan = model.get_plan_by_guid(guid) + plan = model.get(guid) self.assertEqual(plan.name, name) self.assertEqual(plan.updated_at, updated_at) self.assertEqual(plan.created_at, created_at) @@ -174,19 +174,19 @@ def test_update_plan_updated_at(self): with freeze_time('2013-08-16 08:35:40'): # this should update the updated_at field only with transaction.manager: - model.update_plan(guid) + model.update(guid) updated_at = datetime.datetime.utcnow() - plan = model.get_plan_by_guid(guid) + 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_plan_with_wrong_args(self): + def test_update_with_wrong_args(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='evil gangster charges protection fee from Tom weekly', @@ -196,23 +196,23 @@ def test_update_plan_with_wrong_args(self): # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_plan(guid, wrong_arg=True, neme='john') + model.update(guid, wrong_arg=True, neme='john') - def test_delete_plan(self): + def test_delete(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create_plan( + guid = model.create( company_guid=self.company_guid, plan_type=model.TYPE_CHARGE, name='name', amount=99.99, frequency=model.FREQ_WEEKLY, ) - model.delete_plan(guid) + model.delete(guid) - plan = model.get_plan_by_guid(guid) + plan = model.get(guid) self.assertEqual(plan, None) - plan = model.get_plan_by_guid(guid, ignore_deleted=False) + plan = model.get(guid, ignore_deleted=False) self.assertEqual(plan.deleted, True) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 9bcbf86..d51b2a4 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -20,26 +20,26 @@ def setUp(self): self.customer_model = CustomerModel(self.session) self.plan_model = PlanModel(self.session) with db_transaction.manager: - self.company_guid = self.company_model.create_company('my_secret_key') - self.daily_plan_guid = self.plan_model.create_plan( + 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_plan( + 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_plan( + 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_customer( + self.customer_tom_guid = self.customer_model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/tom', ) @@ -51,22 +51,22 @@ def make_one(self, *args, **kwargs): def test_get_subscription(self): model = self.make_one(self.session) - subscription = model.get_subscription_by_guid('SU_NON_EXIST') + subscription = model.get('SU_NON_EXIST') self.assertEqual(subscription, None) with self.assertRaises(KeyError): - model.get_subscription_by_guid('SU_NON_EXIST', raise_error=True) + model.get('SU_NON_EXIST', raise_error=True) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - subscription = model.get_subscription_by_guid(guid, raise_error=True) + subscription = model.get(guid, raise_error=True) self.assertEqual(subscription.guid, guid) - def test_create_subscription(self): + def test_create(self): model = self.make_one(self.session) discount = 0.8 external_id = '5566_GOOD_BROTHERS' @@ -74,7 +74,7 @@ def test_create_subscription(self): plan_guid = self.monthly_plan_guid with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=customer_guid, plan_guid=plan_guid, discount=discount, @@ -83,7 +83,7 @@ def test_create_subscription(self): now = datetime.datetime.utcnow() - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(subscription.guid, guid) self.assert_(subscription.guid.startswith('SU')) self.assertEqual(subscription.customer_guid, customer_guid) @@ -98,79 +98,79 @@ def test_create_subscription(self): self.assertEqual(subscription.created_at, now) self.assertEqual(subscription.updated_at, now) - def test_create_subscription_with_started_at(self): + 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_subscription( + guid = model.create( customer_guid=customer_guid, plan_guid=plan_guid, started_at=started_at ) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(subscription.guid, guid) self.assertEqual(subscription.started_at, started_at) - def test_create_subscription_with_negtive_discount(self): + def test_create_with_negtive_discount(self): model = self.make_one(self.session) with self.assertRaises(ValueError): with db_transaction.manager: - model.create_subscription( + model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, discount=-0.1, ) - def test_update_subscription(self): + def test_update(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, discount=0.1, external_id='old external id' ) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) discount = 0.3 external_id = 'new external id' with db_transaction.manager: - model.update_subscription( + model.update( guid=guid, discount=discount, external_id=external_id, ) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(subscription.discount, discount) self.assertEqual(subscription.external_id, external_id) - def test_update_subscription_updated_at(self): + def test_update_updated_at(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - subscription = model.get_subscription_by_guid(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_subscription(guid=guid) + model.update(guid=guid) updated_at = datetime.datetime.utcnow() - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(subscription.canceled_at, None) self.assertEqual(subscription.updated_at, updated_at) self.assertEqual(subscription.created_at, created_at) @@ -179,38 +179,38 @@ def test_update_subscription_updated_at(self): with freeze_time('2013-08-16 08:35:40'): # this should update the updated_at field only with db_transaction.manager: - model.update_subscription(guid) + model.update(guid) updated_at = datetime.datetime.utcnow() - subscription = model.get_subscription_by_guid(guid) + 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_subscription_with_wrong_args(self): + def test_update_with_wrong_args(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + 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_subscription(guid, wrong_arg=True, neme='john') + 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_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - model.cancel_subscription(guid) + model.cancel(guid) now = datetime.datetime.utcnow() - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(subscription.canceled, True) self.assertEqual(subscription.canceled_at, now) @@ -218,7 +218,7 @@ def test_subscription_cancel_with_prorated_refund(self): model = self.make_one(self.session) with db_transaction.manager: - model.create_subscription( + model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) @@ -229,14 +229,14 @@ def test_subscription_cancel_twice(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - model.cancel_subscription(guid) + model.cancel(guid) with self.assertRaises(SubscriptionCanceledError): - model.cancel_subscription(guid) + model.cancel(guid) def test_yield_transactions(self): from billy.models.transaction import TransactionModel @@ -247,7 +247,7 @@ def test_yield_transactions(self): now = datetime.datetime.utcnow() with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) @@ -255,7 +255,7 @@ def test_yield_transactions(self): self.assertEqual(len(tx_guids), 1) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) transactions = subscription.transactions self.assertEqual(len(transactions), 1) @@ -274,7 +274,7 @@ def test_yield_transactions(self): with db_transaction.manager: tx_guids = model.yield_transactions() self.assertFalse(tx_guids) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(len(subscription.transactions), 1) # should not yield new transaction as 09-16 is the date @@ -282,7 +282,7 @@ def test_yield_transactions(self): with db_transaction.manager: tx_guids = model.yield_transactions() self.assertFalse(tx_guids) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(len(subscription.transactions), 1) # okay, should yield new transaction now @@ -291,10 +291,10 @@ def test_yield_transactions(self): tx_guids = model.yield_transactions() scheduled_at = datetime.datetime.utcnow() self.assertEqual(len(tx_guids), 1) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(len(subscription.transactions), 2) - transaction = tx_model.get_transaction_by_guid(tx_guids[0]) + 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, @@ -308,7 +308,7 @@ def test_yield_transactions_with_multiple_period(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) @@ -319,7 +319,7 @@ def test_yield_transactions_with_multiple_period(self): tx_guids = model.yield_transactions() self.assertEqual(len(set(tx_guids)), 3) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(len(subscription.transactions), 3) sub_tx_guids = [tx.guid for tx in subscription.transactions] @@ -337,19 +337,19 @@ def test_yield_transactions_with_payout(self): model = self.make_one(self.session) with db_transaction.manager: - plan_guid = self.plan_model.create_plan( + 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_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=plan_guid, ) model.yield_transactions() - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) transaction = subscription.transactions[0] self.assertEqual(transaction.transaction_type, TransactionModel.TYPE_PAYOUT) @@ -358,7 +358,7 @@ def test_yield_transactions_with_started_at(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, started_at=datetime.datetime(2013, 9, 1), @@ -368,7 +368,7 @@ def test_yield_transactions_with_started_at(self): tx_guids = model.yield_transactions() self.assertFalse(tx_guids) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertFalse(subscription.transactions) # @@ -377,7 +377,7 @@ def test_yield_transactions_with_started_at(self): tx_guids = model.yield_transactions() self.assertEqual(len(set(tx_guids)), 1) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertEqual(len(subscription.transactions), 1) transaction = subscription.transactions[0] @@ -388,11 +388,11 @@ def test_yield_transactions_with_wrong_type(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) subscription.plan.plan_type = 999 self.session.add(subscription.plan) @@ -403,14 +403,16 @@ def test_yield_transactions_with_canceled_subscription(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_subscription( + guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, started_at=datetime.datetime(2013, 9, 1), ) - model.cancel_subscription(guid) + model.cancel(guid) tx_guids = model.yield_transactions() self.assertFalse(tx_guids) - subscription = model.get_subscription_by_guid(guid) + subscription = model.get(guid) self.assertFalse(subscription.transactions) + + # TODO: test cancel in middle diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index ce3f3c5..70b2386 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -22,18 +22,18 @@ def setUp(self): self.plan_model = PlanModel(self.session) self.subscription_model = SubscriptionModel(self.session) with db_transaction.manager: - self.company_guid = self.company_model.create_company('my_secret_key') - self.plan_guid = self.plan_model.create_plan( + 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_customer( + self.customer_guid = self.customer_model.create( company_guid=self.company_guid, payment_uri='/v1/credit_card/tester', ) - self.subscription_guid = self.subscription_model.create_subscription( + self.subscription_guid = self.subscription_model.create( customer_guid=self.customer_guid, plan_guid=self.plan_guid, ) @@ -45,14 +45,14 @@ def make_one(self, *args, **kwargs): def test_get_transaction(self): model = self.make_one(self.session) - transaction = model.get_transaction_by_guid('TX_NON_EXIST') + transaction = model.get('TX_NON_EXIST') self.assertEqual(transaction, None) with self.assertRaises(KeyError): - model.get_transaction_by_guid('TX_NON_EXIST', raise_error=True) + model.get('TX_NON_EXIST', raise_error=True) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, @@ -60,10 +60,10 @@ def test_get_transaction(self): scheduled_at=datetime.datetime.utcnow(), ) - transaction = model.get_transaction_by_guid(guid, raise_error=True) + transaction = model.get(guid, raise_error=True) self.assertEqual(transaction.guid, guid) - def test_create_transaction(self): + def test_create(self): model = self.make_one(self.session) subscription_guid = self.subscription_guid @@ -74,7 +74,7 @@ def test_create_transaction(self): scheduled_at = now + datetime.timedelta(days=1) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=subscription_guid, transaction_type=transaction_type, amount=amount, @@ -82,7 +82,7 @@ def test_create_transaction(self): scheduled_at=scheduled_at, ) - transaction = model.get_transaction_by_guid(guid) + transaction = model.get(guid) self.assertEqual(transaction.guid, guid) self.assert_(transaction.guid.startswith('TX')) self.assertEqual(transaction.subscription_guid, subscription_guid) @@ -94,11 +94,11 @@ def test_create_transaction(self): self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) - def test_create_transaction_with_wrong_type(self): + def test_create_with_wrong_type(self): model = self.make_one(self.session) with self.assertRaises(ValueError): - model.create_transaction( + model.create( subscription_guid=self.subscription_guid, transaction_type=999, amount=123, @@ -106,11 +106,11 @@ def test_create_transaction_with_wrong_type(self): scheduled_at=datetime.datetime.utcnow(), ) - def test_update_transaction(self): + def test_update(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, @@ -118,23 +118,23 @@ def test_update_transaction(self): scheduled_at=datetime.datetime.utcnow(), ) - transaction = model.get_transaction_by_guid(guid) + transaction = model.get(guid) status = model.STATUS_DONE with db_transaction.manager: - model.update_transaction( + model.update( guid=guid, status=status, ) - transaction = model.get_transaction_by_guid(guid) + transaction = model.get(guid) self.assertEqual(transaction.status, status) - def test_update_transaction_updated_at(self): + def test_update_updated_at(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, @@ -142,16 +142,16 @@ def test_update_transaction_updated_at(self): scheduled_at=datetime.datetime.utcnow(), ) - transaction = model.get_transaction_by_guid(guid) + 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_transaction(guid=guid) + model.update(guid=guid) updated_at = datetime.datetime.utcnow() - transaction = model.get_transaction_by_guid(guid) + transaction = model.get(guid) self.assertEqual(transaction.updated_at, updated_at) self.assertEqual(transaction.created_at, created_at) @@ -159,18 +159,18 @@ def test_update_transaction_updated_at(self): with freeze_time('2013-08-16 08:35:40'): # this should update the updated_at field only with db_transaction.manager: - model.update_transaction(guid) + model.update(guid) updated_at = datetime.datetime.utcnow() - transaction = model.get_transaction_by_guid(guid) + transaction = model.get(guid) self.assertEqual(transaction.updated_at, updated_at) self.assertEqual(transaction.created_at, created_at) - def test_update_transaction_with_wrong_args(self): + def test_update_with_wrong_args(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, @@ -180,17 +180,17 @@ def test_update_transaction_with_wrong_args(self): # make sure passing wrong argument will raise error with self.assertRaises(TypeError): - model.update_transaction( + model.update( guid=guid, wrong_arg=True, status=model.STATUS_INIT ) - def test_update_transaction_with_wrong_status(self): + def test_update_with_wrong_status(self): model = self.make_one(self.session) with db_transaction.manager: - guid = model.create_transaction( + guid = model.create( subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, @@ -199,7 +199,7 @@ def test_update_transaction_with_wrong_status(self): ) with self.assertRaises(ValueError): - model.update_transaction( + model.update( guid=guid, status=999, ) From 49ea4a1a3b472a73eeed02298188431869dcbe00 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 09:50:16 +0800 Subject: [PATCH 045/158] Format multiline statement style --- billy/models/__init__.py | 11 +++-------- billy/models/company.py | 8 +++++--- billy/models/customer.py | 8 +++++--- billy/models/plan.py | 8 +++++--- billy/models/subscription.py | 14 +++++++++----- billy/models/transaction.py | 6 ++++-- 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/billy/models/__init__.py b/billy/models/__init__.py index a36886f..d682f46 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -4,21 +4,16 @@ from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker from zope.sqlalchemy import ZopeTransactionExtension - - -from sqlalchemy import engine_from_config -from sqlalchemy.orm import scoped_session -from sqlalchemy.orm import sessionmaker -from zope.sqlalchemy import ZopeTransactionExtension - + def setup_database(**settings): """Setup database """ if 'engine' not in settings: - settings['engine'] = \ + settings['engine'] = ( engine_from_config(settings, 'sqlalchemy.') + ) if 'session' not in settings: settings['session'] = scoped_session(sessionmaker( diff --git a/billy/models/company.py b/billy/models/company.py index 2a8a501..9873a81 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -18,10 +18,12 @@ def get(self, guid, raise_error=False, ignore_deleted=True): :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) \ + 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 diff --git a/billy/models/customer.py b/billy/models/customer.py index ba447a9..f78d356 100644 --- a/billy/models/customer.py +++ b/billy/models/customer.py @@ -17,10 +17,12 @@ def get(self, guid, raise_error=False, ignore_deleted=True): :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) \ + 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 diff --git a/billy/models/plan.py b/billy/models/plan.py index 6c6b116..c69a837 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -43,10 +43,12 @@ def get(self, guid, raise_error=False, ignore_deleted=True): :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) \ + 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 diff --git a/billy/models/subscription.py b/billy/models/subscription.py index b134795..eeda5db 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -27,9 +27,11 @@ def get(self, guid, raise_error=False): :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) \ + 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 @@ -122,10 +124,12 @@ def yield_transactions(self, now=None): # in this case, we need to make sure all transactions are yielded while True: # find subscriptions which should yield new transactions - subscriptions = self.session.query(Subscription) \ - .filter(Subscription.next_transaction_at <= now) \ - .filter(not_(Subscription.canceled)) \ + subscriptions = ( + self.session.query(Subscription) + .filter(Subscription.next_transaction_at <= now) + .filter(not_(Subscription.canceled)) .all() + ) # okay, we have no more transaction to process, just break if not subscriptions: diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 2e65abb..c460ee6 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -46,9 +46,11 @@ def get(self, guid, raise_error=False): :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) \ + 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 From e47fdc4d6fc47bcb8c84255356e55c8be7b7d983 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 12:24:46 +0800 Subject: [PATCH 046/158] Replace deprecated relation with relationship --- billy/models/tables.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index 1e3b6f9..263532f 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -9,7 +9,7 @@ from sqlalchemy import Numeric from sqlalchemy import Float from sqlalchemy.schema import ForeignKey -from sqlalchemy.orm import relation +from sqlalchemy.orm import relationship from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import func @@ -64,9 +64,9 @@ class Company(DeclarativeBase): updated_at = Column(DateTime(timezone=True), default=now_func) #: plans of this company - plans = relation('Plan', cascade='all, delete-orphan', backref='company') + plans = relationship('Plan', cascade='all, delete-orphan', backref='company') #: customers of this company - customers = relation('Customer', cascade='all, delete-orphan', backref='company') + customers = relationship('Customer', cascade='all, delete-orphan', backref='company') class Customer(DeclarativeBase): @@ -100,7 +100,7 @@ class Customer(DeclarativeBase): updated_at = Column(DateTime(timezone=True), default=now_func) #: subscriptions of this customer - subscriptions = relation('Subscription', cascade='all, delete-orphan', backref='customer') + subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='customer') class Plan(DeclarativeBase): @@ -144,7 +144,7 @@ class Plan(DeclarativeBase): updated_at = Column(DateTime(timezone=True), default=now_func) #: subscriptions of this plan - subscriptions = relation('Subscription', cascade='all, delete-orphan', backref='plan') + subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='plan') class Subscription(DeclarativeBase): @@ -196,7 +196,7 @@ class Subscription(DeclarativeBase): updated_at = Column(DateTime(timezone=True), default=now_func) #: transactions of this subscription - transactions = relation('Transaction', cascade='all, delete-orphan', backref='subscription') + transactions = relationship('Transaction', cascade='all, delete-orphan', backref='subscription') class Transaction(DeclarativeBase): From de33cf39340cc4380642e5681c75ebda1699fceb Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 12:27:12 +0800 Subject: [PATCH 047/158] Use Numeric for discount field in subscription table --- billy/models/tables.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index 263532f..a39bc8f 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -176,8 +176,7 @@ class Subscription(DeclarativeBase): ) #: the discount of this subscription, # e.g. 0.3 means 30% price off disscount - # TODO: maybe we should use decimal here? what about accuracy issue? - discount = Column(Float) + discount = Column(Numeric(10, 2)) #: the external ID given by user external_id = Column(Unicode(128), index=True) #: is this subscription canceled? From a20a7ded610b7f5d1ea12ef02c26db60984552f7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 12:41:08 +0800 Subject: [PATCH 048/158] Fix test failure caused by decimal discount --- billy/tests/test_models/test_subscription.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index d51b2a4..8499dd2 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import datetime +import decimal import transaction as db_transaction from freezegun import freeze_time @@ -68,7 +69,7 @@ def test_get_subscription(self): def test_create(self): model = self.make_one(self.session) - discount = 0.8 + discount = decimal.Decimal('0.8') external_id = '5566_GOOD_BROTHERS' customer_guid = self.customer_tom_guid plan_guid = self.monthly_plan_guid @@ -138,7 +139,7 @@ def test_update(self): ) subscription = model.get(guid) - discount = 0.3 + discount = decimal.Decimal('0.3') external_id = 'new external id' with db_transaction.manager: From bc1fedbc75593a0b520410e170f5b91f6e4be7ba Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 12:54:32 +0800 Subject: [PATCH 049/158] Add interval argument support to schedule model --- billy/models/schedule.py | 16 +- billy/tests/test_models/test_schedule.py | 231 +++++++++++++++++------ 2 files changed, 181 insertions(+), 66 deletions(-) diff --git a/billy/models/schedule.py b/billy/models/schedule.py index fa27e99..821ccae 100644 --- a/billy/models/schedule.py +++ b/billy/models/schedule.py @@ -5,26 +5,30 @@ from billy.models.plan import PlanModel -def next_transaction_datetime(started_at, frequency, period): +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 - very first transaction + 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) + delta = relativedelta(days=period * interval) elif frequency == PlanModel.FREQ_WEEKLY: - delta = relativedelta(weeks=period) + delta = relativedelta(weeks=period * interval) elif frequency == PlanModel.FREQ_MONTHLY: - delta = relativedelta(months=period) + delta = relativedelta(months=period * interval) elif frequency == PlanModel.FREQ_YEARLY: - delta = relativedelta(years=period) + delta = relativedelta(years=period * interval) return started_at + delta diff --git a/billy/tests/test_models/test_schedule.py b/billy/tests/test_models/test_schedule.py index f4b338e..fd1c57a 100644 --- a/billy/tests/test_models/test_schedule.py +++ b/billy/tests/test_models/test_schedule.py @@ -8,11 +8,16 @@ @freeze_time('2013-08-16') class TestSchedule(unittest.TestCase): - def assert_schedule(self, started_at, frequency, length, expected): + 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, frequency, period) + dt = next_transaction_datetime( + started_at=started_at, + frequency=frequency, + period=period, + interval=interval, + ) result.append(dt) self.assertEqual(result, expected) @@ -25,18 +30,41 @@ def test_daily_schedule(self): from billy.models.plan import PlanModel with freeze_time('2013-07-28'): now = datetime.datetime.utcnow() - self.assert_schedule(now, PlanModel.FREQ_DAILY, 10, [ - 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), - ]) + 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 @@ -69,72 +97,155 @@ def test_weekly_schedule(self): from billy.models.plan import PlanModel with freeze_time('2013-08-18'): now = datetime.datetime.utcnow() - self.assert_schedule(now, PlanModel.FREQ_WEEKLY, 5, [ - 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), - ]) + 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(now, PlanModel.FREQ_MONTHLY, 6, [ - 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), - ]) + 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(now, PlanModel.FREQ_MONTHLY, 7, [ - 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), - ]) + 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(now, PlanModel.FREQ_MONTHLY, 6, [ - 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), - ]) + 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(now, PlanModel.FREQ_YEARLY, 5, [ - 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), - ]) + 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(now, PlanModel.FREQ_YEARLY, 5, [ - 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), - ]) + 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), + ] + ) From 0cd38ef1fed66ddd5cf8acfbcb66876c224cb721 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 13:03:10 +0800 Subject: [PATCH 050/158] Add schedule with interval in data model --- billy/models/plan.py | 4 +++ billy/models/subscription.py | 1 + billy/models/tables.py | 5 ++-- billy/tests/test_models/test_plan.py | 29 ++++++++++++++++++++ billy/tests/test_models/test_subscription.py | 25 +++++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/billy/models/plan.py b/billy/models/plan.py index c69a837..47f6de0 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -59,6 +59,7 @@ def create( plan_type, amount, frequency, + interval=1, external_id=None, name=None, description=None, @@ -70,12 +71,15 @@ def create( 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') 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, diff --git a/billy/models/subscription.py b/billy/models/subscription.py index eeda5db..65fa2ad 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -157,6 +157,7 @@ def yield_transactions(self, now=None): started_at=subscription.started_at, frequency=subscription.plan.frequency, period=subscription.period, + interval=subscription.plan.interval, ) self.session.add(subscription) self.session.flush() diff --git a/billy/models/tables.py b/billy/models/tables.py index a39bc8f..e7f75c3 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -133,9 +133,10 @@ class Plan(DeclarativeBase): # 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 - # TODO: this is just a rough implementation, should allow - # a more flexiable setting later 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 diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/test_models/test_plan.py index a8e414b..ac199d2 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/test_models/test_plan.py @@ -54,6 +54,7 @@ def test_create(self): 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' @@ -64,6 +65,7 @@ def test_create(self): name=name, amount=amount, frequency=frequency, + interval=interval, external_id=external_id, description=description, ) @@ -77,6 +79,7 @@ def test_create(self): 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) @@ -84,6 +87,32 @@ def test_create(self): self.assertEqual(plan.created_at, now) self.assertEqual(plan.updated_at, now) + 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) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 8499dd2..299aae6 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -333,6 +333,31 @@ def test_yield_transactions_with_multiple_period(self): datetime.datetime(2013, 10, 16), ]) + 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) From dfdefa30057276939751a44411c71efb1edcf270 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 13:10:30 +0800 Subject: [PATCH 051/158] Improve testing coverage for schedule model --- billy/tests/test_models/test_schedule.py | 25 +++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/billy/tests/test_models/test_schedule.py b/billy/tests/test_models/test_schedule.py index fd1c57a..d0b1565 100644 --- a/billy/tests/test_models/test_schedule.py +++ b/billy/tests/test_models/test_schedule.py @@ -24,7 +24,30 @@ def assert_schedule(self, started_at, frequency, interval, length, expected): def test_invalid_freq_type(self): from billy.models.schedule import next_transaction_datetime with self.assertRaises(ValueError): - next_transaction_datetime(datetime.datetime.utcnow(), 999, 0) + 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 From 8edd12373e3aa746609a434354db3618086f90f8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 13:35:42 +0800 Subject: [PATCH 052/158] Remove unused import --- billy/models/tables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index e7f75c3..f83942b 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -7,7 +7,6 @@ from sqlalchemy import Boolean from sqlalchemy import DateTime from sqlalchemy import Numeric -from sqlalchemy import Float from sqlalchemy.schema import ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.ext.declarative import declarative_base From b72d8359e2c648a08de2c2179adb4f7ab014d245 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 13:36:09 +0800 Subject: [PATCH 053/158] Add test for yield_transactions --- billy/tests/test_models/test_subscription.py | 34 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 299aae6..80baa64 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -436,9 +436,39 @@ def test_yield_transactions_with_canceled_subscription(self): ) model.cancel(guid) - tx_guids = model.yield_transactions() + with db_transaction.manager: + tx_guids = model.yield_transactions() + self.assertFalse(tx_guids) subscription = model.get(guid) self.assertFalse(subscription.transactions) - # TODO: test cancel in middle + 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) From 5d2a870152c8f93c42507cf64e07d8add82ae770 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 15:00:56 +0800 Subject: [PATCH 054/158] Implement cancel subscription with prorated refund --- billy/models/subscription.py | 42 ++++++++++++++++++-- billy/models/tables.py | 25 +++++++++++- billy/models/transaction.py | 20 +++++++++- billy/tests/test_models/test_subscription.py | 26 +++++++++--- billy/tests/test_models/test_transaction.py | 32 +++++++++++++++ 5 files changed, 133 insertions(+), 12 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 65fa2ad..0a69805 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import logging +import decimal from billy.models import tables from billy.models.plan import PlanModel @@ -95,11 +96,46 @@ def cancel(self, guid, prorated_refund=False): now = tables.now_func() subscription.canceled = True subscription.canceled_at = now - if prorated_refund: - # TODO: handle prorated refund here - pass + tx_guid = None + # 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 and subscription.period: + previous_transaction = ( + self.session.query(tables.Transaction) + .filter_by(subscription_guid=subscription.guid) + .order_by(tables.Transaction.scheduled_at.desc()) + .first() + ) + 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 = elapsed_seconds / total_seconds + amount = previous_transaction.amount * rate + + tx_model = TransactionModel(self.session) + # 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, + ) + self.session.add(subscription) self.session.flush() + return tx_guid def yield_transactions(self, now=None): """Generate new necessary transactions according to subscriptions we diff --git a/billy/models/tables.py b/billy/models/tables.py index f83942b..ef30042 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -9,6 +9,7 @@ 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 @@ -195,7 +196,8 @@ class Subscription(DeclarativeBase): updated_at = Column(DateTime(timezone=True), default=now_func) #: transactions of this subscription - transactions = relationship('Transaction', cascade='all, delete-orphan', backref='subscription') + transactions = relationship('Transaction', cascade='all, delete-orphan', + backref='subscription') class Transaction(DeclarativeBase): @@ -217,6 +219,15 @@ class Transaction(DeclarativeBase): 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) #: current status of this transaction, could be @@ -226,10 +237,20 @@ class Transaction(DeclarativeBase): #: the amount to do transaction (charge, payout or refund) amount = Column(Numeric(10, 2), index=True, nullable=False) #: the payment URI - payment_uri = Column(Unicode(128), index=True, nullable=False) + payment_uri = Column(Unicode(128), index=True) #: the scheduled datetime of this transaction should be processed scheduled_at = Column(DateTime(timezone=True), default=now_func) #: the created datetime of this subscription created_at = Column(DateTime(timezone=True), default=now_func) #: the updated datetime of this subscription updated_at = Column(DateTime(timezone=True), 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 index c460ee6..8b897ab 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -60,14 +60,31 @@ def create( subscription_guid, transaction_type, amount, - payment_uri, 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_REFUND: + raise ValueError('Cannot set refund_to_guid to a refund ' + 'transaction') + else: + if payment_uri is None: + raise ValueError('payment_uri can only be None for refund ' + 'transactions') + transaction = tables.Transaction( guid='TX' + make_guid(), subscription_guid=subscription_guid, @@ -76,6 +93,7 @@ def create( payment_uri=payment_uri, status=self.STATUS_INIT, scheduled_at=scheduled_at, + refund_to_guid=refund_to_guid, ) self.session.add(transaction) self.session.flush() diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 80baa64..23b7a90 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -216,14 +216,28 @@ def test_subscription_cancel(self): self.assertEqual(subscription.canceled_at, now) 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 db_transaction.manager: - model.create( - customer_guid=self.customer_tom_guid, - plan_guid=self.monthly_plan_guid, - ) - # TODO: check prorated refund here + 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() + + # 15 / 30 days, the rate should be 0.5 + with freeze_time('2013-06-16'): + 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('5')) def test_subscription_cancel_twice(self): from billy.models.subscription import SubscriptionCanceledError diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 70b2386..c7b8c0a 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import datetime +import decimal import transaction as db_transaction from freezegun import freeze_time @@ -94,6 +95,37 @@ def test_create(self): self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) + def test_create_with_refund_to_guid(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/credit_card/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_with_wrong_type(self): model = self.make_one(self.session) From 7eccb8da35ac337241fe780fde943a82aa9c4d25 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 16:06:46 +0800 Subject: [PATCH 055/158] Add tests for subscription and transaction model --- billy/models/tables.py | 2 +- billy/models/transaction.py | 3 +- billy/tests/test_models/test_subscription.py | 18 ++++ billy/tests/test_models/test_transaction.py | 98 +++++++++++++++++++- 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index ef30042..137b7b4 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -39,7 +39,7 @@ def now_func(): """Return current datetime """ - func = _now_func[0] + func = get_now_func() return func() diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 8b897ab..0af0142 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -68,7 +68,8 @@ def create( """ if transaction_type not in self.TYPE_ALL: - raise ValueError('Invalid transaction_type {}'.format(transaction_type)) + 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 ' diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 23b7a90..f81af8e 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -239,6 +239,24 @@ def test_subscription_cancel_with_prorated_refund(self): self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) self.assertEqual(transaction.amount, decimal.Decimal('5')) + 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() + 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) diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index c7b8c0a..0cf792b 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -95,7 +95,20 @@ def test_create(self): self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) - def test_create_with_refund_to_guid(self): + def test_create_with_none_payment_uri(self): + model = self.make_one(self.session) + + now = datetime.datetime.utcnow() + + with self.assertRaises(ValueError): + model.create( + subscription_guid=self.subscription_guid, + transaction_type=model.TYPE_CHARGE, + amount=100, + scheduled_at=now, + ) + + def test_create_refund(self): model = self.make_one(self.session) now = datetime.datetime.utcnow() @@ -126,6 +139,89 @@ def test_create_with_refund_to_guid(self): 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/credit_card/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/credit_card/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/credit_card/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/credit_card/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, + ) + def test_create_with_wrong_type(self): model = self.make_one(self.session) From 1315a1b55a2e4ba5cb08487e3dd82ef39945efeb Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 16:41:07 +0800 Subject: [PATCH 056/158] Update documents of subscription model --- billy/models/subscription.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 0a69805..6ea1eea 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -69,6 +69,9 @@ def create( def update(self, guid, **kwargs): """Update a subscription + :param guid: the guid of subscription to update + :param discount: discount to update + :param external_id: external_id to update """ subscription = self.get(guid, raise_error=True) now = tables.now_func() @@ -86,8 +89,11 @@ def update(self, guid, **kwargs): def cancel(self, guid, prorated_refund=False): """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? + :return: if prorated_refund is True, and the subscription is + refundable, the refund transaction guid will be returned """ subscription = self.get(guid, raise_error=True) if subscription.canceled: @@ -143,7 +149,7 @@ def yield_transactions(self, now=None): :param now: the current date time to use, now_func() will be used by default - :return: generated transaction guid list + :return: a generated transaction guid list """ from sqlalchemy.sql.expression import not_ From 8f4932660022fef8b97253010f77a8fd95c3d41d Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 19 Aug 2013 16:52:45 +0800 Subject: [PATCH 057/158] Add tests for subscription discount --- billy/models/subscription.py | 6 ++- billy/tests/test_models/test_subscription.py | 50 +++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 6ea1eea..2735e58 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -185,11 +185,15 @@ def yield_transactions(self, now=None): else: raise ValueError('Unknown plan type {} to process' .format(subscription.plan.plan_type)) + amount = subscription.plan.amount + if subscription.discount is not None: + # TODO: what about float number round up? + amount *= (1 - subscription.discount) # create the new transaction for this subscription guid = tx_model.create( subscription_guid=subscription.guid, payment_uri=subscription.customer.payment_uri, - amount=subscription.plan.amount, + amount=amount, transaction_type=transaction_type, scheduled_at=subscription.next_transaction_at, ) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index f81af8e..23eed58 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -239,6 +239,32 @@ def test_subscription_cancel_with_prorated_refund(self): self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) self.assertEqual(transaction.amount, decimal.Decimal('5')) + def test_subscription_cancel_with_prorated_refund_and_discount(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, + discount=0.25, + ) + model.yield_transactions() + + # 15 / 30 days, the rate should be 0.5 + with freeze_time('2013-06-16'): + with db_transaction.manager: + refund_guid = model.cancel(guid, prorated_refund=True) + + transaction = tx_model.get(refund_guid) + # the orignal price is 10, the discount is 0.25, + # so the amount of transaction is 7.5, and we refund half, + # then the refund amount should be 3.75 + self.assertEqual(transaction.amount, decimal.Decimal('3.75')) + # TODO: what about float number round up issue? + def test_subscription_cancel_with_zero_refund(self): model = self.make_one(self.session) @@ -252,7 +278,6 @@ def test_subscription_cancel_with_zero_refund(self): 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) @@ -365,6 +390,29 @@ def test_yield_transactions_with_multiple_period(self): datetime.datetime(2013, 10, 16), ]) + def test_yield_transactions_with_discount(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, + discount=0.25, + ) + + # 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('7.5'), + decimal.Decimal('7.5'), + decimal.Decimal('7.5'), + ]) + def test_yield_transactions_with_multiple_interval(self): model = self.make_one(self.session) From c68b7d9bd0b303fc6469bbdb06eed32af1b454db Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 08:59:15 +0800 Subject: [PATCH 058/158] Fix #35, round down money to cent --- billy/models/subscription.py | 3 +++ billy/tests/test_models/test_subscription.py | 22 ++++++++++++++++++- billy/tests/test_utils/test_generic.py | 23 +++++++++++++++++++- billy/utils/generic.py | 10 +++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 2735e58..830fc94 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -7,6 +7,7 @@ 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): @@ -126,6 +127,7 @@ def cancel(self, guid, prorated_refund=False): # such as day or hour granularity? rate = elapsed_seconds / total_seconds amount = previous_transaction.amount * rate + amount = round_down_cent(amount) tx_model = TransactionModel(self.session) # make sure we will not refund zero dollar @@ -189,6 +191,7 @@ def yield_transactions(self, now=None): if subscription.discount is not None: # TODO: what about float number round up? amount *= (1 - subscription.discount) + amount = round_down_cent(amount) # create the new transaction for this subscription guid = tx_model.create( subscription_guid=subscription.guid, diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 23eed58..807c61b 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -263,7 +263,27 @@ def test_subscription_cancel_with_prorated_refund_and_discount(self): # so the amount of transaction is 7.5, and we refund half, # then the refund amount should be 3.75 self.assertEqual(transaction.amount, decimal.Decimal('3.75')) - # TODO: what about float number round up issue? + + 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, + ) + model.yield_transactions() + + # 17 / 30 days, the rate should be 0.56666... + 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('5.66')) def test_subscription_cancel_with_zero_refund(self): model = self.make_one(self.session) diff --git a/billy/tests/test_utils/test_generic.py b/billy/tests/test_utils/test_generic.py index 96d837f..a09c17c 100644 --- a/billy/tests/test_utils/test_generic.py +++ b/billy/tests/test_utils/test_generic.py @@ -1,5 +1,4 @@ from __future__ import unicode_literals -import math import unittest @@ -28,3 +27,25 @@ def test_make_api_key(self): # 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/generic.py b/billy/utils/generic.py index 964f3ed..4a9f125 100644 --- a/billy/utils/generic.py +++ b/billy/utils/generic.py @@ -2,6 +2,7 @@ import os import uuid +import decimal B58_CHARS = b'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' B58_BASE = len(B58_CHARS) @@ -52,3 +53,12 @@ def make_api_key(size=32): # 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) From 36924729a8ba07878eadd89e53477bae92c64f2b Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 14:07:14 +0800 Subject: [PATCH 059/158] Adjust model, move payment_uri from customer to subscription --- billy/models/customer.py | 8 ++--- billy/models/subscription.py | 4 ++- billy/models/tables.py | 10 +++--- billy/models/transaction.py | 4 --- billy/tests/test_models/test_customer.py | 34 +++----------------- billy/tests/test_models/test_subscription.py | 4 ++- billy/tests/test_models/test_transaction.py | 15 +-------- 7 files changed, 17 insertions(+), 62 deletions(-) diff --git a/billy/models/customer.py b/billy/models/customer.py index f78d356..feff3d6 100644 --- a/billy/models/customer.py +++ b/billy/models/customer.py @@ -20,7 +20,7 @@ def get(self, guid, raise_error=False, ignore_deleted=True): query = ( self.session.query(tables.Customer) .filter_by(guid=guid) - .filter_by(deleted=not ignore_deleted) + .filter_by(deleted=(not ignore_deleted)) .first() ) if raise_error and query is None: @@ -30,8 +30,6 @@ def get(self, guid, raise_error=False, ignore_deleted=True): def create( self, company_guid, - payment_uri, - name=None, external_id=None ): """Create a customer and return its id @@ -40,9 +38,7 @@ def create( customer = tables.Customer( guid='CU' + make_guid(), company_guid=company_guid, - payment_uri=payment_uri, external_id=external_id, - name=name, ) self.session.add(customer) self.session.flush() @@ -55,7 +51,7 @@ def update(self, guid, **kwargs): customer = self.get(guid, raise_error=True) now = tables.now_func() customer.updated_at = now - for key in ['name', 'payment_uri', 'external_id']: + for key in ['external_id']: if key not in kwargs: continue value = kwargs.pop(key) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 830fc94..f6d2b22 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -42,6 +42,7 @@ def create( self, customer_guid, plan_guid, + payment_uri=None, started_at=None, external_id=None, discount=None, @@ -59,6 +60,7 @@ def create( customer_guid=customer_guid, plan_guid=plan_guid, discount=discount, + payment_uri=payment_uri, external_id=external_id, started_at=started_at, next_transaction_at=started_at, @@ -195,7 +197,7 @@ def yield_transactions(self, now=None): # create the new transaction for this subscription guid = tx_model.create( subscription_guid=subscription.guid, - payment_uri=subscription.customer.payment_uri, + payment_uri=subscription.payment_uri, amount=amount, transaction_type=transaction_type, scheduled_at=subscription.next_transaction_at, diff --git a/billy/models/tables.py b/billy/models/tables.py index 137b7b4..aa573fa 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -70,7 +70,7 @@ class Company(DeclarativeBase): class Customer(DeclarativeBase): - """A Customer is basically a user to billy system + """A Customer is a target for charging or payout to """ __tablename__ = 'customer' @@ -86,12 +86,8 @@ class Customer(DeclarativeBase): index=True, nullable=False, ) - #: the external ID given by user + #: the ID of customer record in payment processing system external_id = Column(Unicode(128), index=True) - #: the payment URI associated with this customer - payment_uri = Column(Unicode(128), 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 @@ -175,6 +171,8 @@ class Subscription(DeclarativeBase): index=True, nullable=False, ) + #: the payment URI to charge/payout, such as bank account or credit card + payment_uri = Column(Unicode(128), index=True) #: the discount of this subscription, # e.g. 0.3 means 30% price off disscount discount = Column(Numeric(10, 2)) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 0af0142..eea0908 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -81,10 +81,6 @@ def create( if refund_transaction.transaction_type == self.TYPE_REFUND: raise ValueError('Cannot set refund_to_guid to a refund ' 'transaction') - else: - if payment_uri is None: - raise ValueError('payment_uri can only be None for refund ' - 'transactions') transaction = tables.Transaction( guid='TX' + make_guid(), diff --git a/billy/tests/test_models/test_customer.py b/billy/tests/test_models/test_customer.py index 1d01993..54d9371 100644 --- a/billy/tests/test_models/test_customer.py +++ b/billy/tests/test_models/test_customer.py @@ -32,10 +32,7 @@ def test_get_customer(self): model.get('PL_NON_EXIST', raise_error=True) with transaction.manager: - guid = model.create( - company_guid=self.company_guid, - payment_uri='/v1/credit_card/id', - ) + guid = model.create(company_guid=self.company_guid) model.delete(guid) with self.assertRaises(KeyError): @@ -46,15 +43,11 @@ def test_get_customer(self): def test_create(self): model = self.make_one(self.session) - name = 'Tom' - payment_uri = '/v1/credit_card/id' external_id = '5566_GOOD_BROTHERS' with transaction.manager: guid = model.create( company_guid=self.company_guid, - payment_uri=payment_uri, - name=name, external_id=external_id, ) @@ -64,8 +57,6 @@ def test_create(self): self.assertEqual(customer.guid, guid) self.assert_(customer.guid.startswith('CU')) self.assertEqual(customer.company_guid, self.company_guid) - self.assertEqual(customer.name, name) - self.assertEqual(customer.payment_uri, payment_uri) self.assertEqual(customer.external_id, external_id) self.assertEqual(customer.deleted, False) self.assertEqual(customer.created_at, now) @@ -77,37 +68,26 @@ def test_update(self): with transaction.manager: guid = model.create( company_guid=self.company_guid, - payment_uri='/v1/credit_card/id', external_id='old id', - name='old name', ) customer = model.get(guid) - name = 'new name' - payment_uri = 'new payment uri' external_id = 'new external id' with transaction.manager: model.update( guid=guid, - payment_uri=payment_uri, - name=name, external_id=external_id, ) customer = model.get(guid) - self.assertEqual(customer.name, name) - self.assertEqual(customer.payment_uri, payment_uri) 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, - payment_uri='/v1/credit_card/id', - ) + guid = model.create(company_guid=self.company_guid) customer = model.get(guid) created_at = customer.created_at @@ -137,10 +117,7 @@ def test_update_with_wrong_args(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create( - company_guid=self.company_guid, - payment_uri='/v1/credit_card/id', - ) + guid = model.create(company_guid=self.company_guid) # make sure passing wrong argument will raise error with self.assertRaises(TypeError): @@ -150,10 +127,7 @@ def test_delete(self): model = self.make_one(self.session) with transaction.manager: - guid = model.create( - company_guid=self.company_guid, - payment_uri='/v1/credit_card/id', - ) + guid = model.create(company_guid=self.company_guid) model.delete(guid) customer = model.get(guid) diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 807c61b..089cb85 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -42,7 +42,6 @@ def setUp(self): ) self.customer_tom_guid = self.customer_model.create( company_guid=self.company_guid, - payment_uri='/v1/credit_card/tom', ) def make_one(self, *args, **kwargs): @@ -73,6 +72,7 @@ def test_create(self): 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( @@ -80,6 +80,7 @@ def test_create(self): plan_guid=plan_guid, discount=discount, external_id=external_id, + payment_uri=payment_uri, ) now = datetime.datetime.utcnow() @@ -91,6 +92,7 @@ def test_create(self): self.assertEqual(subscription.plan_guid, plan_guid) self.assertEqual(subscription.discount, discount) 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) diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 0cf792b..982e9f9 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -32,11 +32,11 @@ def setUp(self): ) self.customer_guid = self.customer_model.create( company_guid=self.company_guid, - payment_uri='/v1/credit_card/tester', ) 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): @@ -95,19 +95,6 @@ def test_create(self): self.assertEqual(transaction.created_at, now) self.assertEqual(transaction.updated_at, now) - def test_create_with_none_payment_uri(self): - model = self.make_one(self.session) - - now = datetime.datetime.utcnow() - - with self.assertRaises(ValueError): - model.create( - subscription_guid=self.subscription_guid, - transaction_type=model.TYPE_CHARGE, - amount=100, - scheduled_at=now, - ) - def test_create_refund(self): model = self.make_one(self.session) From dfbfd0ee7931e9c703f63c4dbbaa6a44451a880f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 15:16:10 +0800 Subject: [PATCH 060/158] Replace discount field with amount in subscription --- billy/models/subscription.py | 22 ++++----- billy/models/tables.py | 7 ++- billy/tests/test_models/test_subscription.py | 50 ++++++++++---------- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index f6d2b22..769ea5d 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -45,13 +45,13 @@ def create( payment_uri=None, started_at=None, external_id=None, - discount=None, + amount=None, ): """Create a subscription and return its id """ - if discount is not None and discount < 0: - raise ValueError('Discount should be a postive float number') + if amount is not None and amount <= 0: + raise ValueError('Amount should be a non-zero postive float number') if started_at is None: started_at = tables.now_func() # TODO: should we allow a past started_at value? @@ -59,7 +59,7 @@ def create( guid='SU' + make_guid(), customer_guid=customer_guid, plan_guid=plan_guid, - discount=discount, + amount=amount, payment_uri=payment_uri, external_id=external_id, started_at=started_at, @@ -73,13 +73,12 @@ def update(self, guid, **kwargs): """Update a subscription :param guid: the guid of subscription to update - :param discount: discount 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 ['discount', 'external_id']: + for key in ['external_id']: if key not in kwargs: continue value = kwargs.pop(key) @@ -189,11 +188,12 @@ def yield_transactions(self, now=None): else: raise ValueError('Unknown plan type {} to process' .format(subscription.plan.plan_type)) - amount = subscription.plan.amount - if subscription.discount is not None: - # TODO: what about float number round up? - amount *= (1 - subscription.discount) - amount = round_down_cent(amount) + # 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 # create the new transaction for this subscription guid = tx_model.create( subscription_guid=subscription.guid, diff --git a/billy/models/tables.py b/billy/models/tables.py index aa573fa..6f645aa 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -173,9 +173,8 @@ class Subscription(DeclarativeBase): ) #: the payment URI to charge/payout, such as bank account or credit card payment_uri = Column(Unicode(128), index=True) - #: the discount of this subscription, - # e.g. 0.3 means 30% price off disscount - discount = Column(Numeric(10, 2)) + #: 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? @@ -233,7 +232,7 @@ class Transaction(DeclarativeBase): # TODO: what about retry? status = Column(Integer, index=True, nullable=False) #: the amount to do transaction (charge, payout or refund) - amount = Column(Numeric(10, 2), index=True, nullable=False) + amount = Column(Numeric(10, 2), nullable=False) #: the payment URI payment_uri = Column(Unicode(128), index=True) #: the scheduled datetime of this transaction should be processed diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/test_models/test_subscription.py index 089cb85..ebd6166 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/test_models/test_subscription.py @@ -68,7 +68,7 @@ def test_get_subscription(self): def test_create(self): model = self.make_one(self.session) - discount = decimal.Decimal('0.8') + amount = decimal.Decimal('99.99') external_id = '5566_GOOD_BROTHERS' customer_guid = self.customer_tom_guid plan_guid = self.monthly_plan_guid @@ -78,7 +78,7 @@ def test_create(self): guid = model.create( customer_guid=customer_guid, plan_guid=plan_guid, - discount=discount, + amount=amount, external_id=external_id, payment_uri=payment_uri, ) @@ -90,7 +90,7 @@ def test_create(self): self.assert_(subscription.guid.startswith('SU')) self.assertEqual(subscription.customer_guid, customer_guid) self.assertEqual(subscription.plan_guid, plan_guid) - self.assertEqual(subscription.discount, discount) + self.assertEqual(subscription.amount, amount) self.assertEqual(subscription.external_id, external_id) self.assertEqual(subscription.payment_uri, payment_uri) self.assertEqual(subscription.period, 0) @@ -118,16 +118,21 @@ def test_create_with_started_at(self): self.assertEqual(subscription.guid, guid) self.assertEqual(subscription.started_at, started_at) - def test_create_with_negtive_discount(self): + def test_create_with_bad_amount(self): model = self.make_one(self.session) with self.assertRaises(ValueError): - with db_transaction.manager: - model.create( - customer_guid=self.customer_tom_guid, - plan_guid=self.monthly_plan_guid, - discount=-0.1, - ) + 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) @@ -136,23 +141,19 @@ def test_update(self): guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, - discount=0.1, external_id='old external id' ) subscription = model.get(guid) - discount = decimal.Decimal('0.3') external_id = 'new external id' with db_transaction.manager: model.update( guid=guid, - discount=discount, external_id=external_id, ) subscription = model.get(guid) - self.assertEqual(subscription.discount, discount) self.assertEqual(subscription.external_id, external_id) def test_update_updated_at(self): @@ -241,7 +242,7 @@ def test_subscription_cancel_with_prorated_refund(self): self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) self.assertEqual(transaction.amount, decimal.Decimal('5')) - def test_subscription_cancel_with_prorated_refund_and_discount(self): + def test_subscription_cancel_with_prorated_refund_and_amount(self): from billy.models.transaction import TransactionModel model = self.make_one(self.session) tx_model = TransactionModel(self.session) @@ -251,7 +252,7 @@ def test_subscription_cancel_with_prorated_refund_and_discount(self): guid = model.create( customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, - discount=0.25, + amount=100, ) model.yield_transactions() @@ -261,10 +262,9 @@ def test_subscription_cancel_with_prorated_refund_and_discount(self): refund_guid = model.cancel(guid, prorated_refund=True) transaction = tx_model.get(refund_guid) - # the orignal price is 10, the discount is 0.25, - # so the amount of transaction is 7.5, and we refund half, - # then the refund amount should be 3.75 - self.assertEqual(transaction.amount, decimal.Decimal('3.75')) + # 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('50')) def test_subscription_cancel_with_prorated_refund_rounding(self): from billy.models.transaction import TransactionModel @@ -412,14 +412,14 @@ def test_yield_transactions_with_multiple_period(self): datetime.datetime(2013, 10, 16), ]) - def test_yield_transactions_with_discount(self): + 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, - discount=0.25, + amount=55.66, ) # okay, 08-16, 09-16, 10-16, so we should have 3 new transactions @@ -430,9 +430,9 @@ def test_yield_transactions_with_discount(self): subscription = model.get(guid) amounts = [tx.amount for tx in subscription.transactions] self.assertEqual(amounts, [ - decimal.Decimal('7.5'), - decimal.Decimal('7.5'), - decimal.Decimal('7.5'), + decimal.Decimal('55.66'), + decimal.Decimal('55.66'), + decimal.Decimal('55.66'), ]) def test_yield_transactions_with_multiple_interval(self): From edb445c1803d6cfcc58602d586d732ede7754fb5 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 18:06:57 +0800 Subject: [PATCH 061/158] Add processor and tests --- billy/models/processors/__init__.py | 0 billy/models/processors/balanced_payments.py | 61 +++++++++ billy/models/processors/base.py | 30 +++++ billy/models/tables.py | 2 + billy/models/transaction.py | 45 +++++++ billy/tests/test_models/test_processors.py | 80 ++++++++++++ billy/tests/test_models/test_transaction.py | 127 +++++++++++++++++++ requirements.txt | 3 +- setup.py | 1 + 9 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 billy/models/processors/__init__.py create mode 100644 billy/models/processors/balanced_payments.py create mode 100644 billy/models/processors/base.py create mode 100644 billy/tests/test_models/test_processors.py diff --git a/billy/models/processors/__init__.py b/billy/models/processors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py new file mode 100644 index 0000000..e85c01d --- /dev/null +++ b/billy/models/processors/balanced_payments.py @@ -0,0 +1,61 @@ +import balanced + +from billy.models.processors.base import PaymentProcessor + + +class BalancedProcessor(PaymentProcessor): + + def __init__(self, customer_cls=balanced.Customer, debit_cls=balanced.Debit): + self.customer_cls = customer_cls + self.debit_cls = debit_cls + + def _to_cent(self, amount): + cent = amount * 100 + cent = int(cent) + return cent + + def create_customer(self, customer): + record = self.customer_cls(**{ + 'meta.billy_customer_guid': customer.guid, + }).save() + return record.id + + def prepare_customer(self, customer, payment_uri=None): + if payment_uri is None: + return + record = customer_cls.find(customer.external_id) + # TODO: add payment uri to customer + + def charge(self, transaction): + # make sure we won't duplicate debit + try: + debit = ( + self.debit_cls.query + .filter(**{'meta.billy_transaction_guid': transaction.guid}) + .one() + ) + except balanced.exc.NoResultFound: + debit = None + # we already have a Debit there in Balanced, + # just return it + if debit is not None: + return debit.id + + # TODO: handle error here + + # get customer + external_id = transaction.subscription.customer.external_id + customer = customer_cls.find(external_id) + + # prepare arguments + kwargs = dict(amount=self._to_cent(transaction.amount)) + kwargs['meta.billy_transaction_guid'] =transaction.guid + if transaction.payment_uri is not None: + kwargs['source_uri'] = transaction.payment_uri + + debit = customer.debit(**kwargs) + return debit.id + + def payout(self, transaction_guid, payment_uri, amount): + customer = customer_cls.find(payment_uri) + customer.credit(amount=self._to_cent(amount)) diff --git a/billy/models/processors/base.py b/billy/models/processors/base.py new file mode 100644 index 0000000..00814b4 --- /dev/null +++ b/billy/models/processors/base.py @@ -0,0 +1,30 @@ +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 \ No newline at end of file diff --git a/billy/models/tables.py b/billy/models/tables.py index 6f645aa..d1cc55d 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -227,6 +227,8 @@ class Transaction(DeclarativeBase): ) #: 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 # TODO: what about retry? diff --git a/billy/models/transaction.py b/billy/models/transaction.py index eea0908..7f5b9a2 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -112,3 +112,48 @@ def update(self, guid, **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 + + """ + customer = transaction.subscription.customer + # 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() + + # TODO: handle error + # 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 + else: + method = processor.payout + + # TODO: handle error and retry + transaction_id = method(transaction) + # TODO: generate an invoice here? + + transaction.external_id = transaction_id + transaction.status = self.STATUS_DONE + transaction.updated_at = tables.now_func() + self.session.add(transaction) + self.session.flush() + + def process_transactions(self, processor): + """Process all transactions + + """ + Transaction = tables.Transaction + query = ( + self.session.query(Transaction) + .filter(Transaction.status == self.STATUS_INIT) + .filter(Transaction.type != self.TYPE_REFUND) + ) + + for transaction in query: + self.process_one(processor, transaction.guid) \ No newline at end of file diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py new file mode 100644 index 0000000..b8a7557 --- /dev/null +++ b/billy/tests/test_models/test_processors.py @@ -0,0 +1,80 @@ +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.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) + + +@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): + customer = self.customer_model.get(self.customer_guid) + + mock_balanced_customer = ( + flexmock(id='MOCK_BALANCED_CUSTOMER_ID') + .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_ID') diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 982e9f9..53574ca 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -3,6 +3,7 @@ import decimal import transaction as db_transaction +from flexmock import flexmock from freezegun import freeze_time from billy.tests.helper import ModelTestCase @@ -318,3 +319,129 @@ def test_update_with_wrong_status(self): 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): + from billy.models.processors.base import PaymentProcessor + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/credit_card/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 + + processor = PaymentProcessor() + mocked_processor = flexmock(processor) + ( + mocked_processor + .should_receive('create_customer') + .with_args(customer) + .replace_with(lambda c: 'AC_MOCK') + .once() + ) + ( + mocked_processor + .should_receive('prepare_customer') + .with_args(customer, payment_uri) + .replace_with(lambda c, payment_uri: None) + .once() + ) + ( + mocked_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(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): + from billy.models.processors.base import PaymentProcessor + model = self.make_one(self.session) + now = datetime.datetime.utcnow() + + payment_uri = '/v1/credit_card/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 + + processor = PaymentProcessor() + mocked_processor = flexmock(processor) + ( + mocked_processor + .should_receive('create_customer') + .with_args(customer) + .replace_with(lambda c: 'AC_MOCK') + .once() + ) + ( + mocked_processor + .should_receive('prepare_customer') + .with_args(customer, payment_uri) + .replace_with(lambda c, payment_uri: None) + .once() + ) + ( + mocked_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(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') diff --git a/requirements.txt b/requirements.txt index af481ca..129b4fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ SQLAlchemy==0.8.2 Zope.SQLAlchemy==0.7.2 nose==1.3.0 -python-dateutil==1.5 \ No newline at end of file +python-dateutil==1.5 +balanced==0.11.12 \ No newline at end of file diff --git a/setup.py b/setup.py index f12dcc9..4070464 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'nose-cov', 'webtest', 'freezegun', + 'flexmock', ], install_requires=requires, ) From 185edb1966592e3ce8c1e6d7d6c1f7eddffa06a0 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 23:51:17 +0800 Subject: [PATCH 062/158] Add tests for balanced processor charge method --- billy/models/processors/balanced_payments.py | 19 +++-- billy/tests/test_models/test_processors.py | 90 +++++++++++++++++++- 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index e85c01d..25963db 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -36,24 +36,27 @@ def charge(self, transaction): ) except balanced.exc.NoResultFound: debit = None - # we already have a Debit there in Balanced, - # just return it + # We already have a Debit 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 debit is not None: return debit.id # TODO: handle error here - - # get customer + # get balanced customer record external_id = transaction.subscription.customer.external_id - customer = customer_cls.find(external_id) + balanced_customer = self.customer_cls.find(external_id) # prepare arguments - kwargs = dict(amount=self._to_cent(transaction.amount)) - kwargs['meta.billy_transaction_guid'] =transaction.guid + kwargs = { + 'amount': self._to_cent(transaction.amount), + 'meta.billy_transaction_guid': transaction.guid, + } if transaction.payment_uri is not None: kwargs['source_uri'] = transaction.payment_uri - debit = customer.debit(**kwargs) + # TODO: handle error here + debit = balanced_customer.debit(**kwargs) return debit.id def payout(self, transaction_guid, payment_uri, amount): diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index b8a7557..c5035b1 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -64,6 +64,7 @@ def make_one(self, *args, **kwargs): def test_create_customer(self): customer = self.customer_model.get(self.customer_guid) + # mock balanced customer instance mock_balanced_customer = ( flexmock(id='MOCK_BALANCED_CUSTOMER_ID') .should_receive('save') @@ -72,9 +73,92 @@ def test_create_customer(self): .mock() ) - class BalancedCustomer(object): pass - flexmock(BalancedCustomer).new_instances(mock_balanced_customer) - + 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_ID') + + def test_charge(self): + 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_ID', + ) + transaction = tx_model.get(guid) + + # mock result page object of balanced.Debit.query.filter(...) + + def mock_one(): + raise balanced.exc.NoResultFound + + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(mock_one) + .once() + .mock() + ) + + # mock balanced.Debit.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy_transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.Debit class + class Debit(object): + pass + Debit.query = mock_query + + # mock balanced.Debit instance + mock_debit = flexmock(id='MOCK_BALANCED_DEBIT_ID') + + # mock balanced.Customer instance + mock_balanced_customer = ( + flexmock() + .should_receive('debit') + .with_args(**{ + 'amount': int(transaction.amount * 100), + 'meta.billy_transaction_guid': transaction.guid, + 'source_uri': '/v1/credit_card/tester', + }) + .replace_with(lambda **kw: mock_debit) + .once() + .mock() + ) + + # mock balanced.Customer class + class BalancedCustomer(object): + def find(self, id): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_ID') + .replace_with(lambda _: mock_balanced_customer) + .once() + ) + + processor = self.make_one( + customer_cls=BalancedCustomer, + debit_cls=Debit, + ) + balanced_tx_id = processor.charge(transaction) + self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_DEBIT_ID') From bf25347afce58999fe216d4f051a9b419f4ba873 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 20 Aug 2013 23:56:53 +0800 Subject: [PATCH 063/158] Add test for debit already created case of balanced processor --- billy/models/transaction.py | 3 +- billy/tests/test_models/test_processors.py | 46 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 7f5b9a2..5d5b739 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -133,6 +133,7 @@ def process_one(self, processor, transaction): method = processor.charge else: method = processor.payout + # TODO: support refund here # TODO: handle error and retry transaction_id = method(transaction) @@ -156,4 +157,4 @@ def process_transactions(self, processor): ) for transaction in query: - self.process_one(processor, transaction.guid) \ No newline at end of file + self.process_one(processor, transaction.guid) diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index c5035b1..f5557df 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -162,3 +162,49 @@ def find(self, id): ) balanced_tx_id = processor.charge(transaction) self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_DEBIT_ID') + + def test_charge_already_created(self): + 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) + transaction = tx_model.get(guid) + + # mock result page object of balanced.Debit.query.filter(...) + + mock_page = ( + flexmock() + .should_receive('one') + .replace_with(lambda: mock_debit) + .once() + .mock() + ) + + # mock balanced.Debit.query + mock_query = ( + flexmock() + .should_receive('filter') + .with_args(**{'meta.billy_transaction_guid': transaction.guid}) + .replace_with(lambda **kw: mock_page) + .mock() + ) + + # mock balanced.Debit class + class Debit(object): + pass + Debit.query = mock_query + + # mock balanced.Debit instance + mock_debit = flexmock(id='MOCK_BALANCED_DEBIT_ID') + + processor = self.make_one(debit_cls=Debit) + balanced_tx_id = processor.charge(transaction) + self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_DEBIT_ID') From 5041d4c27802939883178331a6c7eb99cc7ac535 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 00:31:40 +0800 Subject: [PATCH 064/158] Add more tests for balanced processor --- billy/models/processors/balanced_payments.py | 62 ++++++++--- billy/models/processors/base.py | 5 +- billy/tests/test_models/test_processors.py | 104 +++++++++++++------ 3 files changed, 121 insertions(+), 50 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 25963db..5f40bdb 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import balanced from billy.models.processors.base import PaymentProcessor @@ -5,9 +6,15 @@ class BalancedProcessor(PaymentProcessor): - def __init__(self, customer_cls=balanced.Customer, debit_cls=balanced.Debit): + def __init__( + self, + customer_cls=balanced.Customer, + debit_cls=balanced.Debit, + credit_cls=balanced.Credit, + ): self.customer_cls = customer_cls self.debit_cls = debit_cls + self.credit_cls = credit_cls def _to_cent(self, amount): cent = amount * 100 @@ -26,21 +33,27 @@ def prepare_customer(self, customer, payment_uri=None): record = customer_cls.find(customer.external_id) # TODO: add payment uri to customer - def charge(self, transaction): + def _do_transaction( + self, + transaction, + resource_cls, + method_name, + extra_kwargs + ): # make sure we won't duplicate debit try: - debit = ( - self.debit_cls.query + record = ( + resource_cls.query .filter(**{'meta.billy_transaction_guid': transaction.guid}) .one() ) except balanced.exc.NoResultFound: - debit = None - # We already have a Debit there in Balanced, this means we once did + 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 debit is not None: - return debit.id + if record is not None: + return record.id # TODO: handle error here # get balanced customer record @@ -52,13 +65,30 @@ def charge(self, transaction): 'amount': self._to_cent(transaction.amount), 'meta.billy_transaction_guid': transaction.guid, } - if transaction.payment_uri is not None: - kwargs['source_uri'] = transaction.payment_uri - + kwargs.update(extra_kwargs) # TODO: handle error here - debit = balanced_customer.debit(**kwargs) - return debit.id + method = getattr(balanced_customer, method_name) + record = method(**kwargs) + return record.id - def payout(self, transaction_guid, payment_uri, amount): - customer = customer_cls.find(payment_uri) - customer.credit(amount=self._to_cent(amount)) + 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, + ) diff --git a/billy/models/processors/base.py b/billy/models/processors/base.py index 00814b4..d76dd10 100644 --- a/billy/models/processors/base.py +++ b/billy/models/processors/base.py @@ -1,3 +1,6 @@ +from __future__ import unicode_literals + + class PaymentProcessor(object): def create_customer(self, customer): @@ -27,4 +30,4 @@ def payout(self, transaction): """Payout to a account """ - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index f5557df..de743c7 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -81,7 +81,13 @@ class BalancedCustomer(object): customer_id = processor.create_customer(customer) self.assertEqual(customer_id, 'MOCK_BALANCED_CUSTOMER_ID') - def test_charge(self): + def _test_operation( + self, + cls_name, + processor_method_name, + api_method_name, + extra_api_kwargs, + ): import balanced tx_model = self.transaction_model @@ -100,7 +106,7 @@ def test_charge(self): ) transaction = tx_model.get(guid) - # mock result page object of balanced.Debit.query.filter(...) + # mock result page object of balanced.RESOURCE.query.filter(...) def mock_one(): raise balanced.exc.NoResultFound @@ -113,7 +119,7 @@ def mock_one(): .mock() ) - # mock balanced.Debit.query + # mock balanced.RESOURCE.query mock_query = ( flexmock() .should_receive('filter') @@ -122,24 +128,25 @@ def mock_one(): .mock() ) - # mock balanced.Debit class - class Debit(object): + # mock balanced.RESOURCE class + class Resource(object): pass - Debit.query = mock_query + Resource.query = mock_query - # mock balanced.Debit instance - mock_debit = flexmock(id='MOCK_BALANCED_DEBIT_ID') + # mock balanced.RESOURCE instance + mock_resource = flexmock(id='MOCK_BALANCED_RESOURCE_ID') # mock balanced.Customer instance + kwargs = { + 'amount': int(transaction.amount * 100), + 'meta.billy_transaction_guid': transaction.guid, + } + kwargs.update(extra_api_kwargs) mock_balanced_customer = ( flexmock() - .should_receive('debit') - .with_args(**{ - 'amount': int(transaction.amount * 100), - 'meta.billy_transaction_guid': transaction.guid, - 'source_uri': '/v1/credit_card/tester', - }) - .replace_with(lambda **kw: mock_debit) + .should_receive(api_method_name) + .with_args(**kwargs) + .replace_with(lambda **kw: mock_resource) .once() .mock() ) @@ -158,14 +165,17 @@ def find(self, id): processor = self.make_one( customer_cls=BalancedCustomer, - debit_cls=Debit, + **{cls_name: Resource} ) - balanced_tx_id = processor.charge(transaction) - self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_DEBIT_ID') - - def test_charge_already_created(self): - import balanced - + method = getattr(processor, processor_method_name) + balanced_tx_id = method(transaction) + self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_RESOURCE_ID') + + 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( @@ -178,17 +188,19 @@ def test_charge_already_created(self): transaction = tx_model.get(guid) transaction = tx_model.get(guid) - # mock result page object of balanced.Debit.query.filter(...) + # mock balanced.RESOURCE instance + mock_resource = flexmock(id='MOCK_BALANCED_RESOURCE_ID') + # mock result page object of balanced.RESOURCE.query.filter(...) mock_page = ( flexmock() .should_receive('one') - .replace_with(lambda: mock_debit) + .replace_with(lambda: mock_resource) .once() .mock() ) - # mock balanced.Debit.query + # mock balanced.RESOURCE.query mock_query = ( flexmock() .should_receive('filter') @@ -197,14 +209,40 @@ def test_charge_already_created(self): .mock() ) - # mock balanced.Debit class - class Debit(object): + # mock balanced.RESOURCE class + class Resource(object): pass - Debit.query = mock_query + Resource.query = mock_query + + processor = self.make_one(**{cls_name: Resource}) + method = getattr(processor, processor_method_name) + balanced_res_id = method(transaction) + self.assertEqual(balanced_res_id, 'MOCK_BALANCED_RESOURCE_ID') + + 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', + ) - # mock balanced.Debit instance - mock_debit = flexmock(id='MOCK_BALANCED_DEBIT_ID') + 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'), + ) - processor = self.make_one(debit_cls=Debit) - balanced_tx_id = processor.charge(transaction) - self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_DEBIT_ID') + def test_payout_with_created_record(self): + self._test_operation_with_created_record( + cls_name='credit_cls', + processor_method_name='payout', + ) From d604db6d6db169fa81c3e39dc40da2cf5aea1a5e Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 08:16:37 +0800 Subject: [PATCH 065/158] Fix balanced processor bug --- billy/models/processors/balanced_payments.py | 8 ++++---- billy/tests/test_models/test_processors.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 5f40bdb..c571540 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -61,10 +61,10 @@ def _do_transaction( balanced_customer = self.customer_cls.find(external_id) # prepare arguments - kwargs = { - 'amount': self._to_cent(transaction.amount), - 'meta.billy_transaction_guid': transaction.guid, - } + kwargs = dict( + amount=self._to_cent(transaction.amount), + meta=dict(billy_transaction_guid=transaction.guid), + ) kwargs.update(extra_kwargs) # TODO: handle error here method = getattr(balanced_customer, method_name) diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index de743c7..d61d68c 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -137,10 +137,10 @@ class Resource(object): mock_resource = flexmock(id='MOCK_BALANCED_RESOURCE_ID') # mock balanced.Customer instance - kwargs = { - 'amount': int(transaction.amount * 100), - 'meta.billy_transaction_guid': transaction.guid, - } + kwargs = dict( + amount=int(transaction.amount * 100), + meta=dict(billy_transaction_guid=transaction.guid), + ) kwargs.update(extra_api_kwargs) mock_balanced_customer = ( flexmock() From b00aeff0ca77f44c8d40f77e50291c546e3f73e1 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 08:45:06 +0800 Subject: [PATCH 066/158] Add tests for prepare customer of balanced processor --- billy/models/processors/balanced_payments.py | 14 ++- billy/tests/test_models/test_processors.py | 122 +++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index c571540..d19efcf 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -28,10 +28,20 @@ def create_customer(self, customer): return record.id def prepare_customer(self, customer, payment_uri=None): + # when payment_uri is None, it means we are going to use the + # default funding instrument, just return if payment_uri is None: return - record = customer_cls.find(customer.external_id) - # TODO: add payment uri to customer + # 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: + balanced_customer.add_bank_account(payment_uri) + elif '/cards/' in payment_uri: + balanced_customer.add_card(payment_uri) + else: + raise ValueError('Invalid payment_uri {}'.format(payment_uri)) def _do_transaction( self, diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index d61d68c..cff465a 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -81,6 +81,128 @@ class BalancedCustomer(object): customer_id = processor.create_customer(customer) self.assertEqual(customer_id, 'MOCK_BALANCED_CUSTOMER_ID') + def test_prepare_customer_with_card(self): + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_ID', + ) + customer = self.customer_model.get(self.customer_guid) + + # 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, id): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_ID') + .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): + with db_transaction.manager: + self.customer_model.update( + guid=self.customer_guid, + external_id='MOCK_BALANCED_CUSTOMER_ID', + ) + customer = self.customer_model.get(self.customer_guid) + + # 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, id): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_ID') + .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_ID', + ) + 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, id): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_ID') + .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_ID', + ) + 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, id): + pass + ( + flexmock(BalancedCustomer) + .should_receive('find') + .with_args('MOCK_BALANCED_CUSTOMER_ID') + .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, From c703e43510549f09caf53e11550ce1f1b03d0327 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 09:18:47 +0800 Subject: [PATCH 067/158] Add error handling for transaction processing --- billy/models/tables.py | 5 +- billy/models/transaction.py | 59 ++++--- billy/tests/test_models/test_transaction.py | 178 ++++++++++++++++---- 3 files changed, 191 insertions(+), 51 deletions(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index d1cc55d..468551c 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -231,12 +231,15 @@ class Transaction(DeclarativeBase): external_id = Column(Unicode(128), index=True) #: current status of this transaction, could be # 0=init, 1=retrying, 2=done, 3=failed - # TODO: what about retry? 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(timezone=True), default=now_func) #: the created datetime of this subscription diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 5d5b739..790d271 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -117,27 +117,42 @@ def process_one(self, processor, transaction): """Process one transaction """ + if transaction.status == self.STATUS_DONE: + raise ValueError('Cannot process a finished transaction') + now = tables.now_func() customer = transaction.subscription.customer - # 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) + 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() + + # 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 + else: + method = processor.payout + # TODO: support refund here + + 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 + # 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() - - # TODO: handle error - # 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 - else: - method = processor.payout - # TODO: support refund here - - # TODO: handle error and retry - transaction_id = method(transaction) - # TODO: generate an invoice here? + return transaction.external_id = transaction_id transaction.status = self.STATUS_DONE @@ -152,8 +167,10 @@ def process_transactions(self, processor): Transaction = tables.Transaction query = ( self.session.query(Transaction) - .filter(Transaction.status == self.STATUS_INIT) - .filter(Transaction.type != self.TYPE_REFUND) + .filter(Transaction.status.in_([ + self.STATUS_INIT, + self.STATUS_RETRYING] + )) ) for transaction in query: diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index 53574ca..e490e79 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -37,7 +37,7 @@ def setUp(self): self.subscription_guid = self.subscription_model.create( customer_guid=self.customer_guid, plan_guid=self.plan_guid, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', ) def make_one(self, *args, **kwargs): @@ -58,7 +58,7 @@ def test_get_transaction(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -71,7 +71,7 @@ def test_create(self): subscription_guid = self.subscription_guid transaction_type = model.TYPE_CHARGE amount = 100 - payment_uri = '/v1/credit_card/tester' + payment_uri = '/v1/cards/tester' now = datetime.datetime.utcnow() scheduled_at = now + datetime.timedelta(days=1) @@ -92,6 +92,8 @@ def test_create(self): 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) @@ -106,7 +108,7 @@ def test_create_refund(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=100, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=now, ) @@ -149,7 +151,7 @@ def test_create_refund_with_wrong_transaction_type(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=100, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=now, ) model.create( @@ -169,7 +171,7 @@ def test_create_refund_with_payment_uri(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=100, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=now, ) model.create( @@ -178,7 +180,7 @@ def test_create_refund_with_payment_uri(self): refund_to_guid=tx_guid, amount=50, scheduled_at=now, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', ) def test_create_refund_with_wrong_target(self): @@ -190,7 +192,7 @@ def test_create_refund_with_wrong_target(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=100, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=now, ) refund_guid = model.create( @@ -218,7 +220,7 @@ def test_create_with_wrong_type(self): subscription_guid=self.subscription_guid, transaction_type=999, amount=123, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -230,7 +232,7 @@ def test_update(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -254,7 +256,7 @@ def test_update_updated_at(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -290,7 +292,7 @@ def test_update_with_wrong_args(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -310,7 +312,7 @@ def test_update_with_wrong_status(self): subscription_guid=self.subscription_guid, transaction_type=model.TYPE_CHARGE, amount=10, - payment_uri='/v1/credit_card/tester', + payment_uri='/v1/cards/tester', scheduled_at=datetime.datetime.utcnow(), ) @@ -333,11 +335,10 @@ def test_base_processor(self): processor.payout(None) def test_process_one_charge(self): - from billy.models.processors.base import PaymentProcessor model = self.make_one(self.session) now = datetime.datetime.utcnow() - payment_uri = '/v1/credit_card/tester' + payment_uri = '/v1/cards/tester' with db_transaction.manager: guid = model.create( @@ -351,24 +352,23 @@ def test_process_one_charge(self): transaction = model.get(guid) customer = transaction.subscription.customer - processor = PaymentProcessor() - mocked_processor = flexmock(processor) + mock_processor = flexmock() ( - mocked_processor + mock_processor .should_receive('create_customer') .with_args(customer) .replace_with(lambda c: 'AC_MOCK') .once() ) ( - mocked_processor + mock_processor .should_receive('prepare_customer') .with_args(customer, payment_uri) .replace_with(lambda c, payment_uri: None) .once() ) ( - mocked_processor + mock_processor .should_receive('charge') .with_args(transaction) .replace_with(lambda t: 'TX_MOCK') @@ -377,7 +377,7 @@ def test_process_one_charge(self): with freeze_time('2013-08-20'): with db_transaction.manager: - model.process_one(processor, transaction) + model.process_one(mock_processor, transaction) updated_at = datetime.datetime.utcnow() transaction = model.get(guid) @@ -390,11 +390,10 @@ def test_process_one_charge(self): 'AC_MOCK') def test_process_one_payout(self): - from billy.models.processors.base import PaymentProcessor model = self.make_one(self.session) now = datetime.datetime.utcnow() - payment_uri = '/v1/credit_card/tester' + payment_uri = '/v1/cards/tester' with db_transaction.manager: guid = model.create( @@ -408,24 +407,23 @@ def test_process_one_payout(self): transaction = model.get(guid) customer = transaction.subscription.customer - processor = PaymentProcessor() - mocked_processor = flexmock(processor) + mock_processor = flexmock() ( - mocked_processor + mock_processor .should_receive('create_customer') .with_args(customer) .replace_with(lambda c: 'AC_MOCK') .once() ) ( - mocked_processor + mock_processor .should_receive('prepare_customer') .with_args(customer, payment_uri) .replace_with(lambda c, payment_uri: None) .once() ) ( - mocked_processor + mock_processor .should_receive('payout') .with_args(transaction) .replace_with(lambda t: 'TX_MOCK') @@ -434,7 +432,7 @@ def test_process_one_payout(self): with freeze_time('2013-08-20'): with db_transaction.manager: - model.process_one(processor, transaction) + model.process_one(mock_processor, transaction) updated_at = datetime.datetime.utcnow() transaction = model.get(guid) @@ -445,3 +443,125 @@ def test_process_one_payout(self): 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) From 760be7c12bd8b995f09e91bd57b4e141f72bfcaa Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 12:23:12 +0800 Subject: [PATCH 068/158] Use unicode for UUID generation --- billy/tests/test_utils/test_generic.py | 6 +++--- billy/utils/generic.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/billy/tests/test_utils/test_generic.py b/billy/tests/test_utils/test_generic.py index a09c17c..8a11e96 100644 --- a/billy/tests/test_utils/test_generic.py +++ b/billy/tests/test_utils/test_generic.py @@ -10,9 +10,9 @@ def test_make_b58encode(self): def assert_encode(data, expected): self.assertEqual(b58encode(data), expected) - assert_encode(b'', b'1') - assert_encode(b'\00', b'1') - assert_encode(b'hello world', b'StV1DL6CwTryKyV') + assert_encode('', '1') + assert_encode('\00', '1') + assert_encode('hello world', 'StV1DL6CwTryKyV') def test_make_guid(self): from billy.utils.generic import make_guid diff --git a/billy/utils/generic.py b/billy/utils/generic.py index 4a9f125..7bbe6d3 100644 --- a/billy/utils/generic.py +++ b/billy/utils/generic.py @@ -4,7 +4,7 @@ import uuid import decimal -B58_CHARS = b'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +B58_CHARS = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' B58_BASE = len(B58_CHARS) @@ -27,7 +27,7 @@ def b58encode(s): result.append(c) value = div result.append(B58_CHARS[value]) - return b''.join(reversed(result)) + return ''.join(reversed(result)) def make_guid(): From 959d31f38ec482bdfbda724fd988ceefbe5a7156 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 13:53:52 +0800 Subject: [PATCH 069/158] Add description for transaction in balanced --- billy/models/processors/balanced_payments.py | 4 ++++ billy/tests/test_models/test_processors.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index d19efcf..ab2a91e 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -73,6 +73,10 @@ def _do_transaction( # 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=dict(billy_transaction_guid=transaction.guid), ) kwargs.update(extra_kwargs) diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/test_models/test_processors.py index cff465a..97903c0 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/test_models/test_processors.py @@ -262,6 +262,10 @@ class Resource(object): kwargs = dict( amount=int(transaction.amount * 100), meta=dict(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 = ( From 2190e7728fc22bdc179b79029ef0dc709554fb93 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Wed, 21 Aug 2013 14:18:22 +0800 Subject: [PATCH 070/158] Add tests for process_transactions of transaction model --- billy/models/transaction.py | 5 ++- billy/tests/test_models/test_transaction.py | 47 +++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 790d271..6931f5e 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -173,5 +173,8 @@ def process_transactions(self, processor): )) ) + processed_transaction_guids = [] for transaction in query: - self.process_one(processor, transaction.guid) + self.process_one(processor, transaction) + processed_transaction_guids.append(transaction.guid) + return processed_transaction_guids diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/test_models/test_transaction.py index e490e79..292f00e 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/test_models/test_transaction.py @@ -565,3 +565,50 @@ def test_process_one_with_already_done(self): 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])) From a163b0aa8f3ab1b9f0bd3765eef988d243f86f16 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 16:22:16 +0800 Subject: [PATCH 071/158] Add functional tests framework for API --- billy/__init__.py | 23 ++++++ billy/api/__init__.py | 3 + billy/api/company/__init__.py | 3 + billy/api/company/views.py | 43 +++++++++++ billy/api/customer/__init__.py | 3 + billy/api/customer/views.py | 49 +++++++++++++ billy/manage.py | 55 -------------- billy/models/__init__.py | 2 +- billy/renderers.py | 28 ++++++++ billy/request.py | 13 ++++ billy/{tests => scripts}/__init__.py | 0 billy/scripts/initializedb.py | 29 ++++++++ .../{test_models => functional}/__init__.py | 0 billy/tests/functional/helper.py | 24 +++++++ billy/tests/functional/test_company.py | 28 ++++++++ billy/tests/{test_utils => unit}/__init__.py | 0 billy/tests/{ => unit}/helper.py | 1 - billy/tests/unit/test_models/__init__.py | 0 .../{ => unit}/test_models/test_company.py | 2 +- .../{ => unit}/test_models/test_customer.py | 2 +- .../tests/{ => unit}/test_models/test_plan.py | 2 +- .../{ => unit}/test_models/test_processors.py | 2 +- .../{ => unit}/test_models/test_schedule.py | 0 .../test_models/test_subscription.py | 2 +- .../test_models/test_transaction.py | 2 +- billy/tests/unit/test_utils/__init__.py | 0 .../{ => unit}/test_utils/test_generic.py | 0 development.ini | 71 +++++++++++++++++++ production.ini | 62 ++++++++++++++++ requirements.txt | 6 +- setup.py | 6 ++ 31 files changed, 397 insertions(+), 64 deletions(-) create mode 100644 billy/api/__init__.py create mode 100644 billy/api/company/__init__.py create mode 100644 billy/api/company/views.py create mode 100644 billy/api/customer/__init__.py create mode 100644 billy/api/customer/views.py delete mode 100644 billy/manage.py create mode 100644 billy/renderers.py create mode 100644 billy/request.py rename billy/{tests => scripts}/__init__.py (100%) create mode 100644 billy/scripts/initializedb.py rename billy/tests/{test_models => functional}/__init__.py (100%) create mode 100644 billy/tests/functional/helper.py create mode 100644 billy/tests/functional/test_company.py rename billy/tests/{test_utils => unit}/__init__.py (100%) rename billy/tests/{ => unit}/helper.py (96%) create mode 100644 billy/tests/unit/test_models/__init__.py rename billy/tests/{ => unit}/test_models/test_company.py (98%) rename billy/tests/{ => unit}/test_models/test_customer.py (98%) rename billy/tests/{ => unit}/test_models/test_plan.py (99%) rename billy/tests/{ => unit}/test_models/test_processors.py (99%) rename billy/tests/{ => unit}/test_models/test_schedule.py (100%) rename billy/tests/{ => unit}/test_models/test_subscription.py (99%) rename billy/tests/{ => unit}/test_models/test_transaction.py (99%) create mode 100644 billy/tests/unit/test_utils/__init__.py rename billy/tests/{ => unit}/test_utils/test_generic.py (100%) create mode 100644 development.ini create mode 100644 production.ini diff --git a/billy/__init__.py b/billy/__init__.py index e69de29..3eeda24 100644 --- a/billy/__init__.py +++ b/billy/__init__.py @@ -0,0 +1,23 @@ +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, + ) + # provides table entity to json renderers + config.include('.renderers') + # provides api views + config.include('.api') + + config.scan() + return config.make_wsgi_app() diff --git a/billy/api/__init__.py b/billy/api/__init__.py new file mode 100644 index 0000000..820bdcd --- /dev/null +++ b/billy/api/__init__.py @@ -0,0 +1,3 @@ +def includeme(config): + config.include('.company', route_prefix='/v1') + config.include('.customer', route_prefix='/v1') diff --git a/billy/api/company/__init__.py b/billy/api/company/__init__.py new file mode 100644 index 0000000..bdb8352 --- /dev/null +++ b/billy/api/company/__init__.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_route('company', '/companies/{company_guid}') + config.add_route('company_list', '/companies/') diff --git a/billy/api/company/views.py b/billy/api/company/views.py new file mode 100644 index 0000000..470c6fe --- /dev/null +++ b/billy/api/company/views.py @@ -0,0 +1,43 @@ +import transaction as db_transaction +from pyramid.view import view_config + +from billy.models.company import CompanyModel + + +@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 + + """ + model = CompanyModel(request.session) + # TODO: do validation here + processor_key = request.params['processor_key'] + 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 + + """ + model = CompanyModel(request.session) + guid = request.matchdict['company_guid'] + company = model.get(guid) + return company diff --git a/billy/api/customer/__init__.py b/billy/api/customer/__init__.py new file mode 100644 index 0000000..644be9b --- /dev/null +++ b/billy/api/customer/__init__.py @@ -0,0 +1,3 @@ +def includeme(config): + config.add_route('customer', '/customers/{customer_guid}') + config.add_route('customer_list', '/customers/') diff --git a/billy/api/customer/views.py b/billy/api/customer/views.py new file mode 100644 index 0000000..b9324d9 --- /dev/null +++ b/billy/api/customer/views.py @@ -0,0 +1,49 @@ +import transaction as db_transaction +from pyramid.view import view_config + +from billy.models.customer import CustomerModel + + +@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 + + """ + model = CustomerModel(request.session) + # TODO: do validation here + external_id = request.params.get('external_id') + # TODO: company guid should be retrived from API key + # this is just a temporary hack for development + company_guid = request.params.get('company_guid') + 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 + + """ + model = CustomerModel(request.session) + guid = request.matchdict['customer_guid'] + customer = model.get(guid) + return customer diff --git a/billy/manage.py b/billy/manage.py deleted file mode 100644 index 4625e1d..0000000 --- a/billy/manage.py +++ /dev/null @@ -1,55 +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 - """ - print '#'*10, 'Base.metadata', Base.metadata - 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/__init__.py b/billy/models/__init__.py index d682f46..eee36ee 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -6,7 +6,7 @@ from zope.sqlalchemy import ZopeTransactionExtension -def setup_database(**settings): +def setup_database(global_config, **settings): """Setup database """ diff --git a/billy/renderers.py b/billy/renderers.py new file mode 100644 index 0000000..15a6620 --- /dev/null +++ b/billy/renderers.py @@ -0,0 +1,28 @@ +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.created_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.created_at.isoformat(), + ) + + +def includeme(config): + json_renderer = JSON() + json_renderer.add_adapter(tables.Company, company_adapter) + json_renderer.add_adapter(tables.Customer, customer_adapter) + config.add_renderer('json', json_renderer) diff --git a/billy/request.py b/billy/request.py new file mode 100644 index 0000000..6a0f58a --- /dev/null +++ b/billy/request.py @@ -0,0 +1,13 @@ +from pyramid.request import Request +from pyramid.decorator import reify + + +class APIRequest(Request): + + @reify + def session(self): + """Session object for database operations + + """ + settions = self.registry.settings + return settions['session'] diff --git a/billy/tests/__init__.py b/billy/scripts/__init__.py similarity index 100% rename from billy/tests/__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..a902a7e --- /dev/null +++ b/billy/scripts/initializedb.py @@ -0,0 +1,29 @@ +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/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..ae294bc --- /dev/null +++ b/billy/tests/functional/helper.py @@ -0,0 +1,24 @@ +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 + + # init database + settings = { + 'sqlalchemy.url': 'sqlite:///' + } + settings = setup_database({}, **settings) + DeclarativeBase.metadata.create_all(bind=settings['session'].get_bind()) + + app = main({}, **settings) + self.testapp = TestApp(app) + self.testapp.session = settings['session'] + + def tearDown(self): + self.testapp.session.remove() diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py new file mode 100644 index 0000000..7220481 --- /dev/null +++ b/billy/tests/functional/test_company.py @@ -0,0 +1,28 @@ +from billy.tests.functional.helper import ViewTestCase + + +class TestCompanyViews(ViewTestCase): + + def test_create_company(self): + processor_key = 'MOCK_PROCESSOR_KEY' + 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('created_at' in res.json) + self.failUnless('updated_at' in res.json) + + 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'] + res = self.testapp.get('/v1/companies/{}'.format(guid), status=200) + self.assertEqual(res.json, created_company) diff --git a/billy/tests/test_utils/__init__.py b/billy/tests/unit/__init__.py similarity index 100% rename from billy/tests/test_utils/__init__.py rename to billy/tests/unit/__init__.py diff --git a/billy/tests/helper.py b/billy/tests/unit/helper.py similarity index 96% rename from billy/tests/helper.py rename to billy/tests/unit/helper.py index fe0beb6..3efb9be 100644 --- a/billy/tests/helper.py +++ b/billy/tests/unit/helper.py @@ -34,7 +34,6 @@ class ModelTestCase(unittest.TestCase): def setUp(self): from billy.models import tables - from billy.tests.helper import create_session self.session = create_session() self._old_now_func = tables.set_now_func(datetime.datetime.utcnow) 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/test_models/test_company.py b/billy/tests/unit/test_models/test_company.py similarity index 98% rename from billy/tests/test_models/test_company.py rename to billy/tests/unit/test_models/test_company.py index 6e55ed9..af77392 100644 --- a/billy/tests/test_models/test_company.py +++ b/billy/tests/unit/test_models/test_company.py @@ -4,7 +4,7 @@ import transaction from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase @freeze_time('2013-08-16') diff --git a/billy/tests/test_models/test_customer.py b/billy/tests/unit/test_models/test_customer.py similarity index 98% rename from billy/tests/test_models/test_customer.py rename to billy/tests/unit/test_models/test_customer.py index 54d9371..4925ae8 100644 --- a/billy/tests/test_models/test_customer.py +++ b/billy/tests/unit/test_models/test_customer.py @@ -4,7 +4,7 @@ import transaction from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase @freeze_time('2013-08-16') diff --git a/billy/tests/test_models/test_plan.py b/billy/tests/unit/test_models/test_plan.py similarity index 99% rename from billy/tests/test_models/test_plan.py rename to billy/tests/unit/test_models/test_plan.py index ac199d2..b3cd44a 100644 --- a/billy/tests/test_models/test_plan.py +++ b/billy/tests/unit/test_models/test_plan.py @@ -5,7 +5,7 @@ import transaction from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase @freeze_time('2013-08-16') diff --git a/billy/tests/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py similarity index 99% rename from billy/tests/test_models/test_processors.py rename to billy/tests/unit/test_models/test_processors.py index 97903c0..827da82 100644 --- a/billy/tests/test_models/test_processors.py +++ b/billy/tests/unit/test_models/test_processors.py @@ -6,7 +6,7 @@ from flexmock import flexmock from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase class TestPaymentProcessorModel(unittest.TestCase): diff --git a/billy/tests/test_models/test_schedule.py b/billy/tests/unit/test_models/test_schedule.py similarity index 100% rename from billy/tests/test_models/test_schedule.py rename to billy/tests/unit/test_models/test_schedule.py diff --git a/billy/tests/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py similarity index 99% rename from billy/tests/test_models/test_subscription.py rename to billy/tests/unit/test_models/test_subscription.py index ebd6166..5af4aa5 100644 --- a/billy/tests/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -5,7 +5,7 @@ import transaction as db_transaction from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase @freeze_time('2013-08-16') diff --git a/billy/tests/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py similarity index 99% rename from billy/tests/test_models/test_transaction.py rename to billy/tests/unit/test_models/test_transaction.py index 292f00e..b4bc98e 100644 --- a/billy/tests/test_models/test_transaction.py +++ b/billy/tests/unit/test_models/test_transaction.py @@ -6,7 +6,7 @@ from flexmock import flexmock from freezegun import freeze_time -from billy.tests.helper import ModelTestCase +from billy.tests.unit.helper import ModelTestCase @freeze_time('2013-08-16') 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/test_utils/test_generic.py b/billy/tests/unit/test_utils/test_generic.py similarity index 100% rename from billy/tests/test_utils/test_generic.py rename to billy/tests/unit/test_utils/test_generic.py diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..068b60a --- /dev/null +++ b/development.ini @@ -0,0 +1,71 @@ +### +# 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 + +# 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 = INFO +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/production.ini b/production.ini new file mode 100644 index 0000000..e0484c1 --- /dev/null +++ b/production.ini @@ -0,0 +1,62 @@ +### +# 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 + +[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 129b4fb..f23c817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,8 @@ SQLAlchemy==0.8.2 Zope.SQLAlchemy==0.7.2 nose==1.3.0 python-dateutil==1.5 -balanced==0.11.12 \ No newline at end of file +balanced==0.11.12 +pyramid==1.4.3 +waitress==0.8.6 +pyramid_debugtoolbar==1.0.6 +pyramid_tm==0.7 \ No newline at end of file diff --git a/setup.py b/setup.py index 4070464..4ff4cd1 100644 --- a/setup.py +++ b/setup.py @@ -36,4 +36,10 @@ 'flexmock', ], install_requires=requires, + entry_points="""\ + [paste.app_factory] + main = billy:main + [console_scripts] + initialize_billy_db = billy.scripts.initializedb:main + """, ) From b5f237bc5d249b4fd537ea48508453d018e139cd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 17:13:27 +0800 Subject: [PATCH 072/158] Add more tests for company API --- billy/api/company/views.py | 3 +++ billy/tests/functional/test_company.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/billy/api/company/views.py b/billy/api/company/views.py index 470c6fe..57b0215 100644 --- a/billy/api/company/views.py +++ b/billy/api/company/views.py @@ -1,5 +1,6 @@ import transaction as db_transaction from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound from billy.models.company import CompanyModel @@ -40,4 +41,6 @@ def company_get(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)) return company diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py index 7220481..3419676 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -12,6 +12,7 @@ def test_create_company(self): ) self.failUnless('processor_key' not in res.json) self.failUnless('guid' in res.json) + self.failUnless('api_key' in res.json) self.failUnless('created_at' in res.json) self.failUnless('updated_at' in res.json) @@ -26,3 +27,6 @@ def test_get_company(self): guid = created_company['guid'] res = self.testapp.get('/v1/companies/{}'.format(guid), status=200) self.assertEqual(res.json, created_company) + + def test_get_non_existing_company(self): + self.testapp.get('/v1/companies/NON_EXIST', status=404) From 80e590d2b48f54d225da1d0aa1fc670fba197487 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 18:05:28 +0800 Subject: [PATCH 073/158] Add customer tests --- billy/api/customer/views.py | 3 +++ billy/tests/functional/test_customer.py | 30 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 billy/tests/functional/test_customer.py diff --git a/billy/api/customer/views.py b/billy/api/customer/views.py index b9324d9..db0dc50 100644 --- a/billy/api/customer/views.py +++ b/billy/api/customer/views.py @@ -1,5 +1,6 @@ import transaction as db_transaction from pyramid.view import view_config +from pyramid.httpexceptions import HTTPNotFound from billy.models.customer import CustomerModel @@ -46,4 +47,6 @@ def customer_get(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)) return customer diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py new file mode 100644 index 0000000..5324728 --- /dev/null +++ b/billy/tests/functional/test_customer.py @@ -0,0 +1,30 @@ +import transaction as db_transaction + +from billy.tests.functional.helper import ViewTestCase + + +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') + + def test_create_customer(self): + res = self.testapp.post('/v1/customers/', status=200) + self.failUnless('guid' in res.json) + self.failUnless('created_at' in res.json) + self.failUnless('updated_at' in res.json) + + def test_get_company(self): + res = self.testapp.post('/v1/customers/', status=200) + created_customer = res.json + + guid = created_customer['guid'] + res = self.testapp.get('/v1/customers/{}'.format(guid), status=200) + self.assertEqual(res.json, created_customer) + + def test_get_non_existing_customer(self): + self.testapp.get('/v1/customers/NON_EXIST', status=404) From c0cbf68c07d6b5e210fe47eaed90e9995101d439 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 18:13:23 +0800 Subject: [PATCH 074/158] Move test requirements to a file from setup.py --- setup.py | 10 +++------- test_requirements.txt | 4 ++++ 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 test_requirements.txt diff --git a/setup.py b/setup.py index 4ff4cd1..f87a7cd 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,13 +30,8 @@ packages=find_packages(), include_package_data=True, zip_safe=False, - tests_require=[ - 'nose-cov', - 'webtest', - 'freezegun', - 'flexmock', - ], install_requires=requires, + tests_require=test_requires, entry_points="""\ [paste.app_factory] main = billy:main diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..643f5ac --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,4 @@ +nose-cov +webtest +freezegun +flexmock \ No newline at end of file From 410d70455e444c315532ce0ee409a00c708097f2 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 18:31:00 +0800 Subject: [PATCH 075/158] Add get_by_api_key to company model --- billy/models/company.py | 14 +++++++++++++ billy/tests/unit/test_models/test_company.py | 21 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/billy/models/company.py b/billy/models/company.py index 9873a81..b6ffd25 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -28,6 +28,20 @@ def get(self, guid, raise_error=False, ignore_deleted=True): raise KeyError('No such company {}'.format(guid)) return query + def get_by_api_key(self, api_key, raise_error=False, ignore_deleted=True): + """Get a company by its API key + + """ + query = ( + self.session.query(tables.Company) + .filter_by(api_key=api_key) + .filter_by(deleted=not ignore_deleted) + .first() + ) + 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 diff --git a/billy/tests/unit/test_models/test_company.py b/billy/tests/unit/test_models/test_company.py index af77392..6772a71 100644 --- a/billy/tests/unit/test_models/test_company.py +++ b/billy/tests/unit/test_models/test_company.py @@ -33,6 +33,27 @@ def test_get(self): 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' From 39c0ae59c6692828432e8385730153f3249fe01a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 18:46:56 +0800 Subject: [PATCH 076/158] Add api key check --- billy/api/auth.py | 14 +++++++++ billy/api/company/views.py | 6 ++-- billy/api/customer/views.py | 11 +++++-- billy/tests/functional/test_company.py | 35 ++++++++++++++++++++-- billy/tests/functional/test_customer.py | 39 +++++++++++++++++++++---- 5 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 billy/api/auth.py diff --git a/billy/api/auth.py b/billy/api/auth.py new file mode 100644 index 0000000..55ba321 --- /dev/null +++ b/billy/api/auth.py @@ -0,0 +1,14 @@ +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(request.remote_user) + if company is None: + raise HTTPForbidden('Invalid API key {}'.format(request.remote_user)) + return company diff --git a/billy/api/company/views.py b/billy/api/company/views.py index 57b0215..ef058fb 100644 --- a/billy/api/company/views.py +++ b/billy/api/company/views.py @@ -3,6 +3,7 @@ from pyramid.httpexceptions import HTTPNotFound from billy.models.company import CompanyModel +from billy.api.auth import auth_api_key @view_config(route_name='company_list', @@ -38,9 +39,8 @@ def company_get(request): """Get and return a company """ - model = CompanyModel(request.session) + company = auth_api_key(request) guid = request.matchdict['company_guid'] - company = model.get(guid) - if company is None: + if guid != company.guid: return HTTPNotFound('No such company {}'.format(guid)) return company diff --git a/billy/api/customer/views.py b/billy/api/customer/views.py index db0dc50..c879fec 100644 --- a/billy/api/customer/views.py +++ b/billy/api/customer/views.py @@ -1,8 +1,10 @@ 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 @view_config(route_name='customer_list', @@ -22,12 +24,11 @@ def customer_list_post(request): """Create a new customer """ + company = auth_api_key(request) model = CustomerModel(request.session) # TODO: do validation here external_id = request.params.get('external_id') - # TODO: company guid should be retrived from API key - # this is just a temporary hack for development - company_guid = request.params.get('company_guid') + company_guid = company.guid with db_transaction.manager: guid = model.create( external_id=external_id, @@ -44,9 +45,13 @@ 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/tests/functional/test_company.py b/billy/tests/functional/test_company.py index 3419676..4ce88e4 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -25,8 +25,39 @@ def test_get_company(self): ) created_company = res.json guid = created_company['guid'] - res = self.testapp.get('/v1/companies/{}'.format(guid), status=200) + 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): - self.testapp.get('/v1/companies/NON_EXIST', status=404) + 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 + ) diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py index 5324728..1cbd8a3 100644 --- a/billy/tests/functional/test_customer.py +++ b/billy/tests/functional/test_customer.py @@ -11,20 +11,47 @@ def setUp(self): 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): - res = self.testapp.post('/v1/customers/', status=200) + 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.failUnless('created_at' in res.json) self.failUnless('updated_at' in res.json) - - def test_get_company(self): - res = self.testapp.post('/v1/customers/', status=200) + self.assertEqual(res.json['external_id'], 'MOCK_EXTERNAL_ID') + + def test_create_customer_with_bad_api_key(self): + self.testapp.post( + '/v1/customers/', + extra_environ=dict(REMOTE_USER='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), status=200) + 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_non_existing_customer(self): - self.testapp.get('/v1/customers/NON_EXIST', status=404) + self.testapp.get( + '/v1/customers/NON_EXIST', + extra_environ=dict(REMOTE_USER=self.api_key), + status=404 + ) From 9a199e81cc3a09eb5ed11630a7af3758ce830d27 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 19:15:16 +0800 Subject: [PATCH 077/158] Add missing unicode_literals --- billy/__init__.py | 2 ++ billy/api/__init__.py | 3 +++ billy/api/auth.py | 2 ++ billy/api/company/__init__.py | 3 +++ billy/api/company/views.py | 2 ++ billy/api/customer/__init__.py | 3 +++ billy/api/customer/views.py | 2 ++ billy/renderers.py | 2 ++ billy/request.py | 2 ++ billy/tests/functional/helper.py | 2 ++ billy/tests/functional/test_company.py | 2 ++ billy/tests/functional/test_customer.py | 19 ++++++++++++++++++- 12 files changed, 43 insertions(+), 1 deletion(-) diff --git a/billy/__init__.py b/billy/__init__.py index 3eeda24..3ae50d6 100644 --- a/billy/__init__.py +++ b/billy/__init__.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from pyramid.config import Configurator from billy.models import setup_database diff --git a/billy/api/__init__.py b/billy/api/__init__.py index 820bdcd..e318837 100644 --- a/billy/api/__init__.py +++ b/billy/api/__init__.py @@ -1,3 +1,6 @@ +from __future__ import unicode_literals + + def includeme(config): config.include('.company', route_prefix='/v1') config.include('.customer', route_prefix='/v1') diff --git a/billy/api/auth.py b/billy/api/auth.py index 55ba321..2bdbead 100644 --- a/billy/api/auth.py +++ b/billy/api/auth.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from pyramid.httpexceptions import HTTPForbidden from billy.models.company import CompanyModel diff --git a/billy/api/company/__init__.py b/billy/api/company/__init__.py index bdb8352..2c37bec 100644 --- a/billy/api/company/__init__.py +++ b/billy/api/company/__init__.py @@ -1,3 +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/views.py b/billy/api/company/views.py index ef058fb..3a24d20 100644 --- a/billy/api/company/views.py +++ b/billy/api/company/views.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import transaction as db_transaction from pyramid.view import view_config from pyramid.httpexceptions import HTTPNotFound diff --git a/billy/api/customer/__init__.py b/billy/api/customer/__init__.py index 644be9b..bb975cd 100644 --- a/billy/api/customer/__init__.py +++ b/billy/api/customer/__init__.py @@ -1,3 +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/views.py b/billy/api/customer/views.py index c879fec..3065232 100644 --- a/billy/api/customer/views.py +++ b/billy/api/customer/views.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import transaction as db_transaction from pyramid.view import view_config from pyramid.httpexceptions import HTTPNotFound diff --git a/billy/renderers.py b/billy/renderers.py index 15a6620..dccf7eb 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from pyramid.renderers import JSON from billy.models import tables diff --git a/billy/request.py b/billy/request.py index 6a0f58a..6875472 100644 --- a/billy/request.py +++ b/billy/request.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from pyramid.request import Request from pyramid.decorator import reify diff --git a/billy/tests/functional/helper.py b/billy/tests/functional/helper.py index ae294bc..ee7fcb0 100644 --- a/billy/tests/functional/helper.py +++ b/billy/tests/functional/helper.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import unittest diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py index 4ce88e4..bc9973e 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from billy.tests.functional.helper import ViewTestCase diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py index 1cbd8a3..1acacb1 100644 --- a/billy/tests/functional/test_customer.py +++ b/billy/tests/functional/test_customer.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import transaction as db_transaction from billy.tests.functional.helper import ViewTestCase @@ -29,7 +31,7 @@ def test_create_customer(self): def test_create_customer_with_bad_api_key(self): self.testapp.post( '/v1/customers/', - extra_environ=dict(REMOTE_USER='BAD_API_KEY'), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), status=403, ) @@ -49,6 +51,21 @@ def test_get_customer(self): ) 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', From c3c3fbd8c5704293dda815c04659b1e58d09cdaa Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 19:36:55 +0800 Subject: [PATCH 078/158] Add plan API and tests --- billy/api/__init__.py | 1 + billy/api/plan/__init__.py | 6 ++ billy/api/plan/views.py | 70 +++++++++++++++ billy/renderers.py | 27 ++++++ billy/tests/functional/test_plan.py | 131 ++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 billy/api/plan/__init__.py create mode 100644 billy/api/plan/views.py create mode 100644 billy/tests/functional/test_plan.py diff --git a/billy/api/__init__.py b/billy/api/__init__.py index e318837..9076da4 100644 --- a/billy/api/__init__.py +++ b/billy/api/__init__.py @@ -4,3 +4,4 @@ def includeme(config): config.include('.company', route_prefix='/v1') config.include('.customer', route_prefix='/v1') + config.include('.plan', route_prefix='/v1') 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/views.py b/billy/api/plan/views.py new file mode 100644 index 0000000..02c2b26 --- /dev/null +++ b/billy/api/plan/views.py @@ -0,0 +1,70 @@ +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 + + +@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) + model = PlanModel(request.session) + # TODO: do validation here + plan_type = request.params['plan_type'] + amount = request.params['amount'] + frequency = request.params['frequency'] + interval = int(request.params.get('interval', 1)) + company_guid = company.guid + + 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/renderers.py b/billy/renderers.py index dccf7eb..b6849dd 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -23,8 +23,35 @@ def customer_adapter(customer, request): ) +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.created_at.isoformat(), + ) + + 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) config.add_renderer('json', json_renderer) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py new file mode 100644 index 0000000..0e5e10c --- /dev/null +++ b/billy/tests/functional/test_plan.py @@ -0,0 +1,131 @@ +from __future__ import unicode_literals + +import transaction as db_transaction + +from billy.tests.functional.helper import ViewTestCase + + +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 + + 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.failUnless('created_at' in res.json) + self.failUnless('updated_at' in res.json) + 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) + + 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_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, + ) + created_plan = res.json + + guid = created_plan['guid'] + res = self.testapp.get( + '/v1/plans/{}'.format(guid), + extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), + status=403, + ) From 9808edba5158e99604689877a7a9913a7ac6395a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 20:03:24 +0800 Subject: [PATCH 079/158] Add tests for cross company resource access --- billy/api/company/views.py | 9 +++++-- billy/tests/functional/test_company.py | 31 +++++++++++++++++++++++++ billy/tests/functional/test_customer.py | 20 ++++++++++++++++ billy/tests/functional/test_plan.py | 25 ++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/billy/api/company/views.py b/billy/api/company/views.py index 3a24d20..1f48b04 100644 --- a/billy/api/company/views.py +++ b/billy/api/company/views.py @@ -3,6 +3,7 @@ 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 @@ -41,8 +42,12 @@ def company_get(request): """Get and return a company """ - company = auth_api_key(request) + api_company = auth_api_key(request) + model = CompanyModel(request.session) guid = request.matchdict['company_guid'] - if guid != 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/tests/functional/test_company.py b/billy/tests/functional/test_company.py index bc9973e..84f10f8 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -17,6 +17,7 @@ def test_create_company(self): self.failUnless('api_key' in res.json) self.failUnless('created_at' in res.json) self.failUnless('updated_at' in res.json) + self.assertEqual(res.json['created_at'], res.json['updated_at']) def test_get_company(self): processor_key = 'MOCK_PROCESSOR_KEY' @@ -63,3 +64,33 @@ def test_get_non_existing_company(self): 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 index 1acacb1..726632c 100644 --- a/billy/tests/functional/test_customer.py +++ b/billy/tests/functional/test_customer.py @@ -26,6 +26,7 @@ def test_create_customer(self): self.failUnless('guid' in res.json) self.failUnless('created_at' in res.json) self.failUnless('updated_at' in res.json) + self.assertEqual(res.json['created_at'], res.json['updated_at']) self.assertEqual(res.json['external_id'], 'MOCK_EXTERNAL_ID') def test_create_customer_with_bad_api_key(self): @@ -72,3 +73,22 @@ def test_get_non_existing_customer(self): 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_plan.py b/billy/tests/functional/test_plan.py index 0e5e10c..c7f3877 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -40,6 +40,7 @@ def test_create_plan(self): self.assertEqual(res.json['amount'], amount) self.assertEqual(res.json['frequency'], frequency) self.assertEqual(res.json['interval'], interval) + self.assertEqual(res.json['created_at'], res.json['updated_at']) def test_create_plan_with_different_types(self): def assert_plan_type(plan_type): @@ -129,3 +130,27 @@ def test_get_plan_with_bad_api_key(self): 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, + ) From ff78157e21f07f477994e3149c7c38cd6ef22575 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 20:05:36 +0800 Subject: [PATCH 080/158] Add test for getting non-exist plan --- billy/tests/functional/test_plan.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index c7f3877..d380f1c 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -111,6 +111,13 @@ def test_get_plan(self): ) 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/', From 63c69cbe27f30ed27a533f85d07c995c8fad814b Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 21:37:33 +0800 Subject: [PATCH 081/158] Add company guid in customer and plan API response --- billy/renderers.py | 2 ++ billy/tests/functional/test_customer.py | 1 + billy/tests/functional/test_plan.py | 1 + 3 files changed, 4 insertions(+) diff --git a/billy/renderers.py b/billy/renderers.py index b6849dd..2585b19 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -20,6 +20,7 @@ def customer_adapter(customer, request): external_id=customer.external_id, created_at=customer.created_at.isoformat(), updated_at=customer.created_at.isoformat(), + company_guid=customer.company_guid, ) @@ -46,6 +47,7 @@ def plan_adapter(plan, request): interval=plan.interval, created_at=plan.created_at.isoformat(), updated_at=plan.created_at.isoformat(), + company_guid=plan.company_guid, ) diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py index 726632c..2bd27f1 100644 --- a/billy/tests/functional/test_customer.py +++ b/billy/tests/functional/test_customer.py @@ -28,6 +28,7 @@ def test_create_customer(self): self.failUnless('updated_at' in res.json) self.assertEqual(res.json['created_at'], res.json['updated_at']) 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( diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index d380f1c..858afa4 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -41,6 +41,7 @@ def test_create_plan(self): self.assertEqual(res.json['frequency'], frequency) self.assertEqual(res.json['interval'], interval) self.assertEqual(res.json['created_at'], res.json['updated_at']) + self.assertEqual(res.json['company_guid'], self.company_guid) def test_create_plan_with_different_types(self): def assert_plan_type(plan_type): From 2063a8f6f46324fc2c12b14ad8503cf74e05f157 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:02:59 +0800 Subject: [PATCH 082/158] Add subscription API --- billy/api/__init__.py | 1 + billy/api/subscription/__init__.py | 6 ++ billy/api/subscription/views.py | 63 +++++++++++++++++++++ billy/renderers.py | 22 ++++++- billy/tests/functional/test_company.py | 12 +++- billy/tests/functional/test_customer.py | 11 +++- billy/tests/functional/test_plan.py | 10 +++- billy/tests/functional/test_subscription.py | 61 ++++++++++++++++++++ 8 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 billy/api/subscription/__init__.py create mode 100644 billy/api/subscription/views.py create mode 100644 billy/tests/functional/test_subscription.py diff --git a/billy/api/__init__.py b/billy/api/__init__.py index 9076da4..5f86ff6 100644 --- a/billy/api/__init__.py +++ b/billy/api/__init__.py @@ -5,3 +5,4 @@ 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') diff --git a/billy/api/subscription/__init__.py b/billy/api/subscription/__init__.py new file mode 100644 index 0000000..279ffe8 --- /dev/null +++ b/billy/api/subscription/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +def includeme(config): + config.add_route('subscription', '/subscriptions/{subscription_guid}') + config.add_route('subscription_list', '/subscriptions/') diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py new file mode 100644 index 0000000..b776f29 --- /dev/null +++ b/billy/api/subscription/views.py @@ -0,0 +1,63 @@ +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.models.plan import PlanModel +from billy.models.subscription import SubscriptionModel +from billy.api.auth import auth_api_key + + +@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) + model = SubscriptionModel(request.session) + plan_model = PlanModel(request.session) + customer_model = CustomerModel(request.session) + # TODO: do validation here + customer_guid = request.params['customer_guid'] + plan_guid = request.params['plan_guid'] + amount = request.params.get('amount') + + 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') + + with db_transaction.manager: + guid = model.create( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + ) + 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) + model = SubscriptionModel(request.session) + guid = request.matchdict['subscription_guid'] + subscription = model.get(guid) + if subscription is None: + return HTTPNotFound('No such subscription {}'.format(guid)) + if subscription.customer.company_guid != company.guid: + return HTTPForbidden('You have no permission to access subscription {}' + .format(guid)) + return subscription diff --git a/billy/renderers.py b/billy/renderers.py index 2585b19..5372a25 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -10,7 +10,7 @@ def company_adapter(company, request): guid=company.guid, api_key=company.api_key, created_at=company.created_at.isoformat(), - updated_at=company.created_at.isoformat(), + updated_at=company.updated_at.isoformat(), ) @@ -19,7 +19,7 @@ def customer_adapter(customer, request): guid=customer.guid, external_id=customer.external_id, created_at=customer.created_at.isoformat(), - updated_at=customer.created_at.isoformat(), + updated_at=customer.updated_at.isoformat(), company_guid=customer.company_guid, ) @@ -46,14 +46,30 @@ def plan_adapter(plan, request): amount=str(plan.amount), interval=plan.interval, created_at=plan.created_at.isoformat(), - updated_at=plan.created_at.isoformat(), + updated_at=plan.updated_at.isoformat(), company_guid=plan.company_guid, ) +def subscription_adapter(subscription, request): + return dict( + guid=subscription.guid, + amount=str(subscription.amount), + 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(), + customer_guid=subscription.customer_guid, + plan_guid=subscription.plan_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) config.add_renderer('json', json_renderer) diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py index 84f10f8..9cc4625 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -1,12 +1,19 @@ 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), @@ -15,9 +22,8 @@ def test_create_company(self): self.failUnless('processor_key' not in res.json) self.failUnless('guid' in res.json) self.failUnless('api_key' in res.json) - self.failUnless('created_at' in res.json) - self.failUnless('updated_at' in res.json) - self.assertEqual(res.json['created_at'], res.json['updated_at']) + self.assertEqual(res.json['created_at'], now_iso) + self.assertEqual(res.json['updated_at'], now_iso) def test_get_company(self): processor_key = 'MOCK_PROCESSOR_KEY' diff --git a/billy/tests/functional/test_customer.py b/billy/tests/functional/test_customer.py index 2bd27f1..40b7843 100644 --- a/billy/tests/functional/test_customer.py +++ b/billy/tests/functional/test_customer.py @@ -1,10 +1,13 @@ 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): @@ -17,6 +20,9 @@ def setUp(self): 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'), @@ -24,9 +30,8 @@ def test_create_customer(self): status=200, ) self.failUnless('guid' in res.json) - self.failUnless('created_at' in res.json) - self.failUnless('updated_at' in res.json) - self.assertEqual(res.json['created_at'], res.json['updated_at']) + 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) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index 858afa4..495136b 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -1,10 +1,13 @@ 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): @@ -21,6 +24,8 @@ def test_create_plan(self): amount = '55.66' frequency = 'weekly' interval = 123 + now = datetime.datetime.utcnow() + now_iso = now.isoformat() res = self.testapp.post( '/v1/plans/', @@ -34,13 +39,12 @@ def test_create_plan(self): status=200, ) self.failUnless('guid' in res.json) - self.failUnless('created_at' in res.json) - self.failUnless('updated_at' 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['created_at'], res.json['updated_at']) self.assertEqual(res.json['company_guid'], self.company_guid) def test_create_plan_with_different_types(self): diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py new file mode 100644 index 0000000..6aaa778 --- /dev/null +++ b/billy/tests/functional/test_subscription.py @@ -0,0 +1,61 @@ +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 + from billy.models.customer import CustomerModel + from billy.models.plan import PlanModel + super(TestPlanViews, 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): + customer_guid = self.customer_guid + plan_guid = self.plan_guid + amount = '55.66' + now = datetime.datetime.utcnow() + now_iso = now.isoformat() + + res = self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=customer_guid, + plan_guid=plan_guid, + amount=amount, + ), + 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'], now_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) From fe2744bf5b951aeb188605e9ce00d3e34fc1de6e Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:17:40 +0800 Subject: [PATCH 083/158] Add missing __init__.py for tests package --- billy/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 billy/tests/__init__.py diff --git a/billy/tests/__init__.py b/billy/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 3f525331730e6a8a2369084d65b1ea8821aa59dd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:24:23 +0800 Subject: [PATCH 084/158] Add more tests for subscription --- billy/tests/functional/test_plan.py | 3 +- billy/tests/functional/test_subscription.py | 97 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index 495136b..9f7344a 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -134,9 +134,8 @@ def test_get_plan_with_bad_api_key(self): extra_environ=dict(REMOTE_USER=self.api_key), status=200, ) - created_plan = res.json - guid = created_plan['guid'] + guid = res.json['guid'] res = self.testapp.get( '/v1/plans/{}'.format(guid), extra_environ=dict(REMOTE_USER=b'BAD_API_KEY'), diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 6aaa778..d9ff398 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -59,3 +59,100 @@ def test_create_subscription(self): 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_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, + ) From e9b6dc304756057f78b5290b079e6b600d0f9fc7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:32:45 +0800 Subject: [PATCH 085/158] Add more tests for subscription --- billy/tests/functional/test_subscription.py | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index d9ff398..d7711fd 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -156,3 +156,59 @@ def test_get_subscription_of_other_company(self): 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 + ) + + other_company = company_model.get(other_company_guid) + other_api_key = str(other_company.api_key) + + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=other_customer_guid, + plan_guid=self.plan_guid, + ), + extra_environ=dict(REMOTE_USER=other_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, + ) + other_company = company_model.get(other_company_guid) + other_api_key = str(other_company.api_key) + + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=other_plan_guid, + ), + extra_environ=dict(REMOTE_USER=other_api_key), + status=403, + ) From 0ba9b97b725b7d9c70ba17a77081ae57734501ed Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:35:26 +0800 Subject: [PATCH 086/158] Update subscription tests --- billy/tests/functional/test_subscription.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index d7711fd..06f8e62 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -171,16 +171,13 @@ def test_create_subscription_to_other_company_customer(self): company_guid=other_company_guid ) - other_company = company_model.get(other_company_guid) - other_api_key = str(other_company.api_key) - self.testapp.post( '/v1/subscriptions/', dict( customer_guid=other_customer_guid, plan_guid=self.plan_guid, ), - extra_environ=dict(REMOTE_USER=other_api_key), + extra_environ=dict(REMOTE_USER=self.api_key), status=403, ) @@ -200,8 +197,6 @@ def test_create_subscription_to_other_company_plan(self): plan_type=plan_model.TYPE_CHARGE, amount=10, ) - other_company = company_model.get(other_company_guid) - other_api_key = str(other_company.api_key) self.testapp.post( '/v1/subscriptions/', @@ -209,6 +204,6 @@ def test_create_subscription_to_other_company_plan(self): customer_guid=self.customer_guid, plan_guid=other_plan_guid, ), - extra_environ=dict(REMOTE_USER=other_api_key), + extra_environ=dict(REMOTE_USER=self.api_key), status=403, ) From 4282871c7055a78d77c03c41b2ee60c3aaf8b7b7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 22:44:07 +0800 Subject: [PATCH 087/158] Fix a typo --- billy/tests/functional/test_subscription.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 06f8e62..fb030c3 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -8,13 +8,13 @@ @freeze_time('2013-08-16') -class TestPlanViews(ViewTestCase): +class TestSubscriptionViews(ViewTestCase): def setUp(self): from billy.models.company import CompanyModel from billy.models.customer import CustomerModel from billy.models.plan import PlanModel - super(TestPlanViews, self).setUp() + super(TestSubscriptionViews, self).setUp() company_model = CompanyModel(self.testapp.session) customer_model = CustomerModel(self.testapp.session) plan_model = PlanModel(self.testapp.session) From 4a7239c63e6c890214c3137a0ff1f2d4c6550acb Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 23:10:36 +0800 Subject: [PATCH 088/158] Add transaction API --- billy/api/__init__.py | 1 + billy/api/subscription/views.py | 2 + billy/api/transaction/__init__.py | 5 + billy/api/transaction/views.py | 27 ++++ billy/renderers.py | 34 ++++ billy/tests/functional/test_transaction.py | 178 +++++++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 billy/api/transaction/__init__.py create mode 100644 billy/api/transaction/views.py create mode 100644 billy/tests/functional/test_transaction.py diff --git a/billy/api/__init__.py b/billy/api/__init__.py index 5f86ff6..dd2d9c2 100644 --- a/billy/api/__init__.py +++ b/billy/api/__init__.py @@ -6,3 +6,4 @@ def includeme(config): 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/subscription/views.py b/billy/api/subscription/views.py index b776f29..6fa1186 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -26,6 +26,7 @@ def subscription_list_post(request): customer_guid = request.params['customer_guid'] plan_guid = request.params['plan_guid'] amount = request.params.get('amount') + # TODO: add started at parameter customer = customer_model.get(customer_guid) if customer.company_guid != company.guid: @@ -40,6 +41,7 @@ def subscription_list_post(request): plan_guid=plan_guid, amount=amount, ) + # TODO: yield transaction and handle right away? 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..0bebae3 --- /dev/null +++ b/billy/api/transaction/__init__.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + + +def includeme(config): + 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..ade1b85 --- /dev/null +++ b/billy/api/transaction/views.py @@ -0,0 +1,27 @@ +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', + 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/renderers.py b/billy/renderers.py index 5372a25..7c22893 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -66,10 +66,44 @@ def subscription_adapter(subscription, request): ) +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/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py new file mode 100644 index 0000000..e58c23a --- /dev/null +++ b/billy/tests/functional/test_transaction.py @@ -0,0 +1,178 @@ +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_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 + 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: + 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, + ) From feec3842293efa00e14905cc41efe60d25275d59 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 23:21:45 +0800 Subject: [PATCH 089/158] Fix a transaction test issue --- billy/tests/functional/test_transaction.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py index e58c23a..604d1d1 100644 --- a/billy/tests/functional/test_transaction.py +++ b/billy/tests/functional/test_transaction.py @@ -140,7 +140,6 @@ def test_get_transaction_of_other_company(self): 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) @@ -170,7 +169,6 @@ def test_get_transaction_of_other_company(self): 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), From 3828abc3b5b38225c7816030ac26750c51856192 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 22 Aug 2013 23:56:04 +0800 Subject: [PATCH 090/158] Add tests for initializedb, and YEAH, 100% test coverage! --- billy/tests/functional/test_initializedb.py | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 billy/tests/functional/test_initializedb.py diff --git a/billy/tests/functional/test_initializedb.py b/billy/tests/functional/test_initializedb.py new file mode 100644 index 0000000..21768a9 --- /dev/null +++ b/billy/tests/functional/test_initializedb.py @@ -0,0 +1,70 @@ +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.usage([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', + ])) + + def test_main_without_enough_args(self): + from billy.scripts import initializedb + + with self.assertRaises(SystemExit): + initializedb.main([initializedb.__file__]) From 289e65fdcdc3a29ae0a265778beffcb8cf597cf0 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 09:31:57 +0800 Subject: [PATCH 091/158] Add process transactions script --- billy/scripts/process_transactions.py | 38 ++++++++++ billy/tests/functional/test_initializedb.py | 8 +-- .../functional/test_process_transactions.py | 71 +++++++++++++++++++ setup.py | 1 + 4 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 billy/scripts/process_transactions.py create mode 100644 billy/tests/functional/test_process_transactions.py diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py new file mode 100644 index 0000000..62b5aca --- /dev/null +++ b/billy/scripts/process_transactions.py @@ -0,0 +1,38 @@ +import os +import sys + +import balanced +import transaction as db_transaction +from pyramid.paster import ( + get_appsettings, + setup_logging, +) + +from billy.models import setup_database +from billy.models.transaction import TransactionModel +from billy.models.processors.balanced_payments import BalancedProcessor + + +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) + + balanced.configure(settings['balanced.api_key']) + session = settings['session'] + tx_model = TransactionModel(session) + processor = BalancedProcessor() + + with db_transaction.manager: + tx_model.process_transactions(processor) + print('done') diff --git a/billy/tests/functional/test_initializedb.py b/billy/tests/functional/test_initializedb.py index 21768a9..04e5c25 100644 --- a/billy/tests/functional/test_initializedb.py +++ b/billy/tests/functional/test_initializedb.py @@ -27,7 +27,7 @@ def test_usage(self): sys.stdout = usage_out try: with self.assertRaises(SystemExit): - initializedb.usage([filename]) + initializedb.main([filename]) finally: sys.stdout = old_stdout expected = textwrap.dedent("""\ @@ -62,9 +62,3 @@ def test_main(self): 'subscription', 'transaction', ])) - - def test_main_without_enough_args(self): - from billy.scripts import initializedb - - with self.assertRaises(SystemExit): - initializedb.main([initializedb.__file__]) diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py new file mode 100644 index 0000000..cf37a40 --- /dev/null +++ b/billy/tests/functional/test_process_transactions.py @@ -0,0 +1,71 @@ +from __future__ import unicode_literals +import os +import sys +import unittest +import tempfile +import shutil +import textwrap +import StringIO + +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): + import balanced + from billy.models.transaction import TransactionModel + from billy.scripts import initializedb + from billy.scripts import process_transactions + + ( + flexmock(balanced) + .should_receive('configure') + .with_args('MOCK_BALANCED_API_KEY') + .once() + ) + + ( + flexmock(TransactionModel) + .should_receive('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 + + balanced.api_key = MOCK_BALANCED_API_KEY + """)) + initializedb.main([initializedb.__file__, cfg_path]) + process_transactions.main([process_transactions.__file__, cfg_path]) + # TODO: do more check here? diff --git a/setup.py b/setup.py index f87a7cd..c6eb9ae 100644 --- a/setup.py +++ b/setup.py @@ -37,5 +37,6 @@ main = billy:main [console_scripts] initialize_billy_db = billy.scripts.initializedb:main + process_billy_tx = billy.scripts.process_transactions:main """, ) From 960e675157bfe36110c0f885b9be6de47234d8c6 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 14:24:57 +0800 Subject: [PATCH 092/158] Add balanced API configure and logging for processor --- billy/models/processors/balanced_payments.py | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index ab2a91e..3df376a 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -1,4 +1,6 @@ from __future__ import unicode_literals +import logging + import balanced from billy.models.processors.base import PaymentProcessor @@ -11,7 +13,9 @@ def __init__( customer_cls=balanced.Customer, debit_cls=balanced.Debit, credit_cls=balanced.Credit, + logger=None, ): + self.logger = logger or logging.getLogger(__name__) self.customer_cls = customer_cls self.debit_cls = debit_cls self.credit_cls = credit_cls @@ -22,12 +26,16 @@ def _to_cent(self, amount): return cent def create_customer(self, customer): + 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.id def prepare_customer(self, customer, payment_uri=None): + 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: @@ -37,9 +45,17 @@ def prepare_customer(self, customer, payment_uri=None): 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)) @@ -50,6 +66,9 @@ def _do_transaction( method_name, extra_kwargs ): + api_key = transaction.subscription.plan.company.processor_key + # TODO: what about thread safty issue? + balanced.configure(api_key) # make sure we won't duplicate debit try: record = ( @@ -63,6 +82,8 @@ def _do_transaction( # 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.id # TODO: handle error here @@ -80,9 +101,11 @@ def _do_transaction( meta=dict(billy_transaction_guid=transaction.guid), ) kwargs.update(extra_kwargs) - # TODO: handle error here + method = getattr(balanced_customer, method_name) + self.logger.debug('Calling %s with args %s', method, kwargs) record = method(**kwargs) + self.logger.info('Called %s with args %s', method, kwargs) return record.id def charge(self, transaction): From 7bca849f47fda12d31251290a06e108714c4a597 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 14:30:26 +0800 Subject: [PATCH 093/158] Add some logging message for transaction model --- billy/models/transaction.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 6931f5e..353c614 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -119,6 +119,7 @@ def process_one(self, processor, transaction): """ if transaction.status == self.STATUS_DONE: raise ValueError('Cannot process a finished transaction') + self.logger.debug('Processing transaction %s', transaction.guid) now = tables.now_func() customer = transaction.subscription.customer try: @@ -159,6 +160,10 @@ def process_one(self, processor, transaction): 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): """Process all transactions From d5d5a23c3bfd93f9c497529db07542d51a166fdf Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 14:35:16 +0800 Subject: [PATCH 094/158] Adjust SQLAlchemy logging level to WARN --- development.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/development.ini b/development.ini index 068b60a..0fb467b 100644 --- a/development.ini +++ b/development.ini @@ -54,7 +54,7 @@ handlers = qualname = billy [logger_sqlalchemy] -level = INFO +level = WARN handlers = qualname = sqlalchemy.engine # "level = INFO" logs SQL queries. From ad091f9f7da593d53bca10f2f6708747baf1bb96 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 14:35:43 +0800 Subject: [PATCH 095/158] Fix balanced API key configuration bug --- billy/scripts/process_transactions.py | 8 +++++--- billy/tests/functional/test_process_transactions.py | 10 ---------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index 62b5aca..3ad52e6 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -1,7 +1,7 @@ import os import sys +import logging -import balanced import transaction as db_transaction from pyramid.paster import ( get_appsettings, @@ -21,6 +21,8 @@ def usage(argv): def main(argv=sys.argv): + logger = logging.getLogger(__name__) + if len(argv) != 2: usage(argv) config_uri = argv[1] @@ -28,11 +30,11 @@ def main(argv=sys.argv): settings = get_appsettings(config_uri) settings = setup_database({}, **settings) - balanced.configure(settings['balanced.api_key']) session = settings['session'] tx_model = TransactionModel(session) processor = BalancedProcessor() + logger.info('Processing transaction ...') with db_transaction.manager: tx_model.process_transactions(processor) - print('done') + logger.info('Done') diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py index cf37a40..f0f1597 100644 --- a/billy/tests/functional/test_process_transactions.py +++ b/billy/tests/functional/test_process_transactions.py @@ -38,18 +38,10 @@ def test_usage(self): self.assertMultiLineEqual(usage_out.getvalue(), expected) def test_main(self): - import balanced from billy.models.transaction import TransactionModel from billy.scripts import initializedb from billy.scripts import process_transactions - ( - flexmock(balanced) - .should_receive('configure') - .with_args('MOCK_BALANCED_API_KEY') - .once() - ) - ( flexmock(TransactionModel) .should_receive('process_transactions') @@ -63,8 +55,6 @@ def test_main(self): use = egg:billy sqlalchemy.url = sqlite:///%(here)s/billy.sqlite - - balanced.api_key = MOCK_BALANCED_API_KEY """)) initializedb.main([initializedb.__file__, cfg_path]) process_transactions.main([process_transactions.__file__, cfg_path]) From 0eb2a21adfd67fec17b63a090840a1d9a9a065ad Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 16:14:58 +0800 Subject: [PATCH 096/158] Add basic authentication parsing --- billy/__init__.py | 2 + billy/api/auth.py | 38 +++++++++++++++ billy/tests/functional/test_auth.py | 75 +++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 billy/tests/functional/test_auth.py diff --git a/billy/__init__.py b/billy/__init__.py index 3ae50d6..6483ce7 100644 --- a/billy/__init__.py +++ b/billy/__init__.py @@ -16,6 +16,8 @@ def main(global_config, **settings): 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 diff --git a/billy/api/auth.py b/billy/api/auth.py index 2bdbead..e795512 100644 --- a/billy/api/auth.py +++ b/billy/api/auth.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +import binascii from pyramid.httpexceptions import HTTPForbidden @@ -14,3 +15,40 @@ def auth_api_key(request): 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/tests/functional/test_auth.py b/billy/tests/functional/test_auth.py new file mode 100644 index 0000000..72d73e7 --- /dev/null +++ b/billy/tests/functional/test_auth.py @@ -0,0 +1,75 @@ +from __future__ import unicode_literals + +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 = 'USERNAME:PASSWORD'.encode('base64') + 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 = 'USERNAME'.encode('base64') + 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 = 'USERNAME:PASSWORD'.encode('base64') + 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]) From 5c30dc305ecf09a3cad82a2757e36f42c6349097 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 16:17:53 +0800 Subject: [PATCH 097/158] Add missing unicode_literals --- billy/scripts/initializedb.py | 1 + billy/scripts/process_transactions.py | 1 + 2 files changed, 2 insertions(+) diff --git a/billy/scripts/initializedb.py b/billy/scripts/initializedb.py index a902a7e..d59a94a 100644 --- a/billy/scripts/initializedb.py +++ b/billy/scripts/initializedb.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import os import sys diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index 3ad52e6..cf50de8 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals import os import sys import logging From 276b67e52ecc27a4c23e5dff16fe4b50a1486d9f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 16:39:21 +0800 Subject: [PATCH 098/158] Add logging for subscription model --- billy/models/subscription.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 769ea5d..8f37d0c 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -178,6 +178,7 @@ def yield_transactions(self, now=None): # 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: @@ -194,6 +195,20 @@ def yield_transactions(self, now=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, @@ -202,6 +217,17 @@ def yield_transactions(self, now=None): 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', + guid, + subscription.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( From a0d8058ee68e78567b24cb2f7c651f6f393dda88 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 16:40:05 +0800 Subject: [PATCH 099/158] Yield transactions in process transactions script --- billy/scripts/process_transactions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index cf50de8..a19841c 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -10,6 +10,7 @@ ) from billy.models import setup_database +from billy.models.subscription import SubscriptionModel from billy.models.transaction import TransactionModel from billy.models.processors.balanced_payments import BalancedProcessor @@ -32,10 +33,13 @@ def main(argv=sys.argv): settings = setup_database({}, **settings) session = settings['session'] + subscription_model = SubscriptionModel(session) tx_model = TransactionModel(session) processor = BalancedProcessor() - logger.info('Processing transaction ...') with db_transaction.manager: + logger.info('Yielding transaction ...') + subscription_model.yield_transactions() + logger.info('Processing transaction ...') tx_model.process_transactions(processor) logger.info('Done') From 5af9d22d16e1b63bd98b3fb616ca4b24ba371456 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Fri, 23 Aug 2013 16:48:43 +0800 Subject: [PATCH 100/158] Fix some logging issue --- billy/models/processors/balanced_payments.py | 4 ++-- billy/models/subscription.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 3df376a..5e1e0c6 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -103,9 +103,9 @@ def _do_transaction( kwargs.update(extra_kwargs) method = getattr(balanced_customer, method_name) - self.logger.debug('Calling %s with args %s', method, kwargs) + self.logger.debug('Calling %s with args %s', method.__name__, kwargs) record = method(**kwargs) - self.logger.info('Called %s with args %s', method, kwargs) + self.logger.info('Called %s with args %s', method.__name__, kwargs) return record.id def charge(self, transaction): diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 8f37d0c..6f3f275 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -220,8 +220,8 @@ def yield_transactions(self, now=None): self.logger.info( 'Created transaction for %s, guid=%s, transaction_type=%s, ' 'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', - guid, subscription.guid, + guid, type_map[transaction_type], subscription.payment_uri, amount, From 4587e7b6b0cc944af8b85954fda63701bc31f0ea Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 11:32:03 +0800 Subject: [PATCH 101/158] Fix created record has different updated_at and created_at bug --- billy/models/company.py | 3 +++ billy/models/customer.py | 3 +++ billy/models/plan.py | 3 +++ billy/models/transaction.py | 3 +++ billy/tests/unit/test_models/test_company.py | 21 +++++++++++++++ billy/tests/unit/test_models/test_customer.py | 20 ++++++++++++++ billy/tests/unit/test_models/test_plan.py | 25 ++++++++++++++++++ .../unit/test_models/test_transaction.py | 26 +++++++++++++++++++ 8 files changed, 104 insertions(+) diff --git a/billy/models/company.py b/billy/models/company.py index b6ffd25..a8c1f5e 100644 --- a/billy/models/company.py +++ b/billy/models/company.py @@ -46,11 +46,14 @@ def create(self, processor_key, name=None): """Create a company and return its id """ + 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(company) self.session.flush() diff --git a/billy/models/customer.py b/billy/models/customer.py index feff3d6..8fe6a9b 100644 --- a/billy/models/customer.py +++ b/billy/models/customer.py @@ -35,10 +35,13 @@ def create( """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() diff --git a/billy/models/plan.py b/billy/models/plan.py index 47f6de0..33eadb1 100644 --- a/billy/models/plan.py +++ b/billy/models/plan.py @@ -73,6 +73,7 @@ def create( 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, @@ -83,6 +84,8 @@ def create( external_id=external_id, name=name, description=description, + updated_at=now, + created_at=now, ) self.session.add(plan) self.session.flush() diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 353c614..a65edcb 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -82,6 +82,7 @@ def create( raise ValueError('Cannot set refund_to_guid to a refund ' 'transaction') + now = tables.now_func() transaction = tables.Transaction( guid='TX' + make_guid(), subscription_guid=subscription_guid, @@ -91,6 +92,8 @@ def create( 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() diff --git a/billy/tests/unit/test_models/test_company.py b/billy/tests/unit/test_models/test_company.py index 6772a71..3c28d95 100644 --- a/billy/tests/unit/test_models/test_company.py +++ b/billy/tests/unit/test_models/test_company.py @@ -3,6 +3,7 @@ import transaction from freezegun import freeze_time +from flexmock import flexmock from billy.tests.unit.helper import ModelTestCase @@ -77,6 +78,26 @@ def test_create(self): 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) diff --git a/billy/tests/unit/test_models/test_customer.py b/billy/tests/unit/test_models/test_customer.py index 4925ae8..808b031 100644 --- a/billy/tests/unit/test_models/test_customer.py +++ b/billy/tests/unit/test_models/test_customer.py @@ -62,6 +62,26 @@ def test_create(self): 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) diff --git a/billy/tests/unit/test_models/test_plan.py b/billy/tests/unit/test_models/test_plan.py index b3cd44a..ea2133a 100644 --- a/billy/tests/unit/test_models/test_plan.py +++ b/billy/tests/unit/test_models/test_plan.py @@ -87,6 +87,31 @@ def test_create(self): 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) diff --git a/billy/tests/unit/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py index b4bc98e..a50216a 100644 --- a/billy/tests/unit/test_models/test_transaction.py +++ b/billy/tests/unit/test_models/test_transaction.py @@ -98,6 +98,32 @@ def test_create(self): 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) From 56b60ff6ce8ec4fa35d79d3ce77f3cd7fc44f346 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 13:43:39 +0800 Subject: [PATCH 102/158] Add list_by_company_guid to transaction model --- billy/models/transaction.py | 23 ++++ .../unit/test_models/test_transaction.py | 126 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index a65edcb..f868361 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -55,6 +55,29 @@ def get(self, guid, raise_error=False): 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 + Company = tables.Company + query = ( + self.session + .query(Transaction) + .join((Subscription, + Subscription.guid == Transaction.subscription_guid)) + .join((Plan, Plan.guid == Subscription.plan_guid)) + .join((Company, Company.guid == Plan.company_guid)) + .filter(Company.guid == company_guid) + ) + 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, diff --git a/billy/tests/unit/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py index a50216a..98c2331 100644 --- a/billy/tests/unit/test_models/test_transaction.py +++ b/billy/tests/unit/test_models/test_transaction.py @@ -65,6 +65,132 @@ def test_get_transaction(self): 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): + 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) From e5415ad45b5890538c6c6373ca22b485c84bd6b5 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 14:00:56 +0800 Subject: [PATCH 103/158] Add transaction list in API --- billy/api/transaction/__init__.py | 1 + billy/api/transaction/views.py | 24 ++++++++++++++++ billy/tests/functional/test_transaction.py | 32 ++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/billy/api/transaction/__init__.py b/billy/api/transaction/__init__.py index 0bebae3..5b40636 100644 --- a/billy/api/transaction/__init__.py +++ b/billy/api/transaction/__init__.py @@ -2,4 +2,5 @@ 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 index ade1b85..7eb55b6 100644 --- a/billy/api/transaction/views.py +++ b/billy/api/transaction/views.py @@ -8,6 +8,30 @@ 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') diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py index 604d1d1..a7472c3 100644 --- a/billy/tests/functional/test_transaction.py +++ b/billy/tests/functional/test_transaction.py @@ -75,6 +75,38 @@ def test_get_transaction(self): 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): + 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) From 772f74d3b183e81cefd847a48de214c2155eb72d Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 14:20:09 +0800 Subject: [PATCH 104/158] Add more log messages in transaction model --- billy/models/transaction.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index f868361..fd157cf 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -156,6 +156,8 @@ def process_one(self, processor, transaction): 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) @@ -174,6 +176,10 @@ def process_one(self, processor, transaction): # 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 From da90b7e5706ffac010c05d2bca24a10a89b3d0ab Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 15:24:27 +0800 Subject: [PATCH 105/158] Add yielding transactions from specific subscriptions --- billy/models/subscription.py | 11 +++++-- .../unit/test_models/test_subscription.py | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 6f3f275..745522d 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -146,10 +146,13 @@ def cancel(self, guid, prorated_refund=False): self.session.flush() return tx_guid - def yield_transactions(self, now=None): + 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 @@ -169,12 +172,14 @@ def yield_transactions(self, now=None): # in this case, we need to make sure all transactions are yielded while True: # find subscriptions which should yield new transactions - subscriptions = ( + query = ( self.session.query(Subscription) .filter(Subscription.next_transaction_at <= now) .filter(not_(Subscription.canceled)) - .all() ) + 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: diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index 5af4aa5..1adb327 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -384,6 +384,36 @@ def test_yield_transactions(self): 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) From 3d42dfd81d287de25d3aae41e37d4f0c05e22ee4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 21:01:41 +0800 Subject: [PATCH 106/158] Use base64 encode instead string.encode('base64') in test_auth.py (avoid tailing newline) --- billy/tests/functional/test_auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/billy/tests/functional/test_auth.py b/billy/tests/functional/test_auth.py index 72d73e7..dc71bd3 100644 --- a/billy/tests/functional/test_auth.py +++ b/billy/tests/functional/test_auth.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +import base64 from webtest.app import TestRequest @@ -14,7 +15,7 @@ def make_one(self): def test_get_remote(self): get_remote_user = self.make_one() - encoded = 'USERNAME:PASSWORD'.encode('base64') + encoded = base64.b64encode('USERNAME:PASSWORD') auth = 'basic {}'.format(encoded) request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) @@ -24,7 +25,7 @@ def test_get_remote(self): def test_get_remote_without_base64_part(self): get_remote_user = self.make_one() - encoded = 'USERNAME'.encode('base64') + encoded = base64.b64encode('USERNAME') auth = 'basic {}'.format(encoded) request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) @@ -58,7 +59,7 @@ def test_get_remote_user_with_empty_environ(self): def test_basic_auth_tween(self): from billy.api.auth import basic_auth_tween_factory - encoded = 'USERNAME:PASSWORD'.encode('base64') + encoded = base64.b64encode('USERNAME:PASSWORD') auth = 'basic {}'.format(encoded) request = TestRequest(dict(HTTP_AUTHORIZATION=auth)) From 54d0eaa1da9a783f39e16547e451bb733787f3e9 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 22:51:11 +0800 Subject: [PATCH 107/158] Add simple integration tests --- billy/tests/integration/__init__.py | 0 billy/tests/integration/helper.py | 21 ++++++++++ billy/tests/integration/test_basic.py | 58 +++++++++++++++++++++++++++ test_requirements.txt | 3 +- 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 billy/tests/integration/__init__.py create mode 100644 billy/tests/integration/helper.py create mode 100644 billy/tests/integration/test_basic.py diff --git a/billy/tests/integration/__init__.py b/billy/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/billy/tests/integration/helper.py b/billy/tests/integration/helper.py new file mode 100644 index 0000000..500cc55 --- /dev/null +++ b/billy/tests/integration/helper.py @@ -0,0 +1,21 @@ +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.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..04bc1ce --- /dev/null +++ b/billy/tests/integration/test_basic.py @@ -0,0 +1,58 @@ +from __future__ import unicode_literals + +from billy.tests.integration.helper import IntegrationTestCase + + +class TestBasicScenarios(IntegrationTestCase): + + def test_simple_subscription(self): + # 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'], + ), + 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']) + + # TODO: check transaction here? diff --git a/test_requirements.txt b/test_requirements.txt index 643f5ac..a077416 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,4 +1,5 @@ nose-cov webtest freezegun -flexmock \ No newline at end of file +flexmock +wsgiproxy2 \ No newline at end of file From 2e3fd45b111dec6d770a14bab913330ba8c16c2f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sat, 24 Aug 2013 22:54:17 +0800 Subject: [PATCH 108/158] Exclude integration tests from default tests --- setup.cfg | 1 + test_requirements.txt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 1ea2b1b..ee245dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,6 @@ [nosetests] match=^test +exclude-dir=billy/tests/integration nocapture=1 with-coverage=1 cover-package=billy diff --git a/test_requirements.txt b/test_requirements.txt index a077416..bc8deb9 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -2,4 +2,5 @@ nose-cov webtest freezegun flexmock -wsgiproxy2 \ No newline at end of file +wsgiproxy2 +nose-exclude \ No newline at end of file From ecbcfd44016b6d615fc6a0fba7d8fb4b6f866b08 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 25 Aug 2013 20:06:27 +0800 Subject: [PATCH 109/158] Add a test for a process_transactions bug --- billy/scripts/process_transactions.py | 5 +- .../functional/test_process_transactions.py | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index a19841c..2990b6e 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -22,7 +22,7 @@ def usage(argv): sys.exit(1) -def main(argv=sys.argv): +def main(argv=sys.argv, processor=None): logger = logging.getLogger(__name__) if len(argv) != 2: @@ -35,7 +35,8 @@ def main(argv=sys.argv): session = settings['session'] subscription_model = SubscriptionModel(session) tx_model = TransactionModel(session) - processor = BalancedProcessor() + if processor is None: + processor = BalancedProcessor() with db_transaction.manager: logger.info('Yielding transaction ...') diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py index f0f1597..661dbe5 100644 --- a/billy/tests/functional/test_process_transactions.py +++ b/billy/tests/functional/test_process_transactions.py @@ -7,6 +7,7 @@ import textwrap import StringIO +import transaction as db_transaction from flexmock import flexmock @@ -59,3 +60,107 @@ def test_main(self): 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 + # raise a KeyboardInterrupt error. So, it would looks 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) From 65a5fbfc6a8124919e49413646e763772176dc7b Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 25 Aug 2013 20:09:00 +0800 Subject: [PATCH 110/158] Fix duplicate processing to processor issue --- billy/scripts/process_transactions.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index 2990b6e..1dc0b54 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -38,9 +38,13 @@ def main(argv=sys.argv, processor=None): if processor is None: processor = BalancedProcessor() + # 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') From fae1546a11c72cef6b0024e221acd28fefa0c4a2 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Sun, 25 Aug 2013 20:24:47 +0800 Subject: [PATCH 111/158] Fix some typos --- billy/tests/functional/test_process_transactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py index 661dbe5..27d9f63 100644 --- a/billy/tests/functional/test_process_transactions.py +++ b/billy/tests/functional/test_process_transactions.py @@ -151,7 +151,7 @@ def charge(self, transaction): # 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 - # raise a KeyboardInterrupt error. So, it would looks like this + # raises a KeyboardInterrupt error. So, it would look like this # # charge for transaction from Subscription1 # charge for transaction from Subscription2 (Crash) From b36d1867ddf23ea795f510a8bd6c1eafa25bf57c Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 10:39:57 +0800 Subject: [PATCH 112/158] Improve list_by_company_guid of transaction model --- billy/models/transaction.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index fd157cf..d31b366 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -62,15 +62,13 @@ def list_by_company_guid(self, company_guid, offset=None, limit=None): Transaction = tables.Transaction Subscription = tables.Subscription Plan = tables.Plan - Company = tables.Company query = ( self.session .query(Transaction) .join((Subscription, Subscription.guid == Transaction.subscription_guid)) .join((Plan, Plan.guid == Subscription.plan_guid)) - .join((Company, Company.guid == Plan.company_guid)) - .filter(Company.guid == company_guid) + .filter(Plan.company_guid == company_guid) ) if offset is not None: query = query.offset(offset) @@ -144,7 +142,8 @@ def process_one(self, processor, transaction): """ if transaction.status == self.STATUS_DONE: - raise ValueError('Cannot process a finished transaction') + 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 From 22720f9ac6b093741dd9eb83340b1da7d01b4203 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 10:52:32 +0800 Subject: [PATCH 113/158] Add list transactions by subscription to transaction model --- billy/models/transaction.py | 16 +++++++++ billy/tests/functional/test_transaction.py | 40 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index d31b366..4d9939d 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -76,6 +76,22 @@ def list_by_company_guid(self, company_guid, offset=None, limit=None): query = query.limit(limit) return query + def list_by_subscription_guid(self, subscription_guid, offset=None, limit=None): + """Get transactions of a subscription by given guid + + """ + Transaction = tables.Transaction + query = ( + self.session + .query(Transaction) + .filter(Transaction.subscription_guid == subscription_guid) + ) + 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, diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py index a7472c3..0d54fea 100644 --- a/billy/tests/functional/test_transaction.py +++ b/billy/tests/functional/test_transaction.py @@ -107,6 +107,46 @@ def test_transaction_list_by_company_with_bad_api_key(self): status=403, ) + def test_transaction_list_by_subscription(self): + from billy.models.transaction import TransactionModel + transaction_model = TransactionModel(self.testapp.session) + guids1 = [self.transaction_guid] + with db_transaction.manager: + # create 4 transactions + for i in range(4): + 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(), + ) + guids1.append(guid) + # create 6 transaction for another subscription + self.subscription_guid2 = subscription_model.create( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + ) + for i in range(6): + guid = transaction_model.create( + subscription_guid=subscription_guid2, + transaction_type=transaction_model.TYPE_CHARGE, + amount=10 * i, + payment_uri='/v1/cards/tester', + scheduled_at=datetime.datetime.utcnow(), + ) + guids2.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_get_transaction_with_different_types(self): from billy.models.transaction import TransactionModel transaction_model = TransactionModel(self.testapp.session) From fd968c1a1f52dac660b4fc763fda3b4c4585e57f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 11:13:03 +0800 Subject: [PATCH 114/158] Yield transactions right after subscription is created --- billy/api/subscription/views.py | 3 ++- billy/tests/functional/test_subscription.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index 6fa1186..b3f1f03 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -41,7 +41,8 @@ def subscription_list_post(request): plan_guid=plan_guid, amount=amount, ) - # TODO: yield transaction and handle right away? + model.yield_transactions([guid]) + # TODO: process transactions right away? subscription = model.get(guid) return subscription diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index fb030c3..e91073c 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -40,6 +40,9 @@ def test_create_subscription(self): amount = '55.66' now = datetime.datetime.utcnow() now_iso = now.isoformat() + # next week + next_transaction_at = datetime.datetime(2013, 8, 23) + next_iso = next_transaction_at.isoformat() res = self.testapp.post( '/v1/subscriptions/', @@ -54,8 +57,8 @@ def test_create_subscription(self): 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'], now_iso) - self.assertEqual(res.json['period'], 0) + 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) From 5064d0040ae4cec71b15e4d181008f9e92f093fb Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 11:28:07 +0800 Subject: [PATCH 115/158] Make processor configurable --- billy/scripts/process_transactions.py | 7 ++++++- billy/tests/functional/test_process_transactions.py | 6 ++++++ development.ini | 2 ++ production.ini | 2 ++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index 1dc0b54..51f2004 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -8,6 +8,7 @@ get_appsettings, setup_logging, ) +from pyramid.path import DottedNameResolver from billy.models import setup_database from billy.models.subscription import SubscriptionModel @@ -35,8 +36,12 @@ def main(argv=sys.argv, processor=None): session = settings['session'] subscription_model = SubscriptionModel(session) tx_model = TransactionModel(session) + + resolver = DottedNameResolver() if processor is None: - processor = BalancedProcessor() + 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. diff --git a/billy/tests/functional/test_process_transactions.py b/billy/tests/functional/test_process_transactions.py index 27d9f63..5977bbe 100644 --- a/billy/tests/functional/test_process_transactions.py +++ b/billy/tests/functional/test_process_transactions.py @@ -40,12 +40,17 @@ def test_usage(self): 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() ) @@ -56,6 +61,7 @@ def test_main(self): 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]) diff --git a/development.ini b/development.ini index 0fb467b..b5c561b 100644 --- a/development.ini +++ b/development.ini @@ -17,6 +17,8 @@ pyramid.includes = 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 diff --git a/production.ini b/production.ini index e0484c1..70bbb6e 100644 --- a/production.ini +++ b/production.ini @@ -16,6 +16,8 @@ pyramid.includes = 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 From 85365ee31da06fc15dbc6dd083d3aa891ccfacc6 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 13:47:04 +0800 Subject: [PATCH 116/158] Add started at support for creating subscription --- billy/api/subscription/views.py | 7 ++++- billy/tests/functional/test_subscription.py | 30 +++++++++++++++++++++ requirements.txt | 3 ++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index b3f1f03..4d918e0 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import iso8601 import transaction as db_transaction from pyramid.view import view_config from pyramid.httpexceptions import HTTPNotFound @@ -26,7 +27,10 @@ def subscription_list_post(request): customer_guid = request.params['customer_guid'] plan_guid = request.params['plan_guid'] amount = request.params.get('amount') - # TODO: add started at parameter + started_at = request.params.get('started_at') + if started_at is not None: + started_at = iso8601.parse_date(started_at) + # TODO: what if it is not in UTC timezone? customer = customer_model.get(customer_guid) if customer.company_guid != company.guid: @@ -40,6 +44,7 @@ def subscription_list_post(request): customer_guid=customer_guid, plan_guid=plan_guid, amount=amount, + started_at=started_at, ) model.yield_transactions([guid]) # TODO: process transactions right away? diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index e91073c..56ae3c0 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -63,6 +63,36 @@ def test_create_subscription(self): self.assertEqual(res.json['customer_guid'], customer_guid) self.assertEqual(res.json['plan_guid'], plan_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_bad_api(self): self.testapp.post( '/v1/subscriptions/', diff --git a/requirements.txt b/requirements.txt index f23c817..e3052ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ balanced==0.11.12 pyramid==1.4.3 waitress==0.8.6 pyramid_debugtoolbar==1.0.6 -pyramid_tm==0.7 \ No newline at end of file +pyramid_tm==0.7 +iso8601==0.1.4 \ No newline at end of file From cac87cdb7e10cf83bcf0da558ac877404b9a613a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 13:51:19 +0800 Subject: [PATCH 117/158] Remove unused code (add them back is they are required) --- billy/models/transaction.py | 16 --------- billy/tests/functional/test_transaction.py | 40 ---------------------- 2 files changed, 56 deletions(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 4d9939d..d31b366 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -76,22 +76,6 @@ def list_by_company_guid(self, company_guid, offset=None, limit=None): query = query.limit(limit) return query - def list_by_subscription_guid(self, subscription_guid, offset=None, limit=None): - """Get transactions of a subscription by given guid - - """ - Transaction = tables.Transaction - query = ( - self.session - .query(Transaction) - .filter(Transaction.subscription_guid == subscription_guid) - ) - 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, diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py index 0d54fea..a7472c3 100644 --- a/billy/tests/functional/test_transaction.py +++ b/billy/tests/functional/test_transaction.py @@ -107,46 +107,6 @@ def test_transaction_list_by_company_with_bad_api_key(self): status=403, ) - def test_transaction_list_by_subscription(self): - from billy.models.transaction import TransactionModel - transaction_model = TransactionModel(self.testapp.session) - guids1 = [self.transaction_guid] - with db_transaction.manager: - # create 4 transactions - for i in range(4): - 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(), - ) - guids1.append(guid) - # create 6 transaction for another subscription - self.subscription_guid2 = subscription_model.create( - customer_guid=self.customer_guid, - plan_guid=self.plan_guid, - ) - for i in range(6): - guid = transaction_model.create( - subscription_guid=subscription_guid2, - transaction_type=transaction_model.TYPE_CHARGE, - amount=10 * i, - payment_uri='/v1/cards/tester', - scheduled_at=datetime.datetime.utcnow(), - ) - guids2.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_get_transaction_with_different_types(self): from billy.models.transaction import TransactionModel transaction_model = TransactionModel(self.testapp.session) From f93b770f82c9ab4ea71b35e6ad67534d6f9a0dd4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 14:25:02 +0800 Subject: [PATCH 118/158] Add time zone convert for started_at parameter for subscription view --- billy/api/subscription/views.py | 5 ++++- billy/tests/functional/test_subscription.py | 23 +++++++++++++++++++++ requirements.txt | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index 4d918e0..e32c2f1 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import pytz import iso8601 import transaction as db_transaction from pyramid.view import view_config @@ -30,7 +31,9 @@ def subscription_list_post(request): started_at = request.params.get('started_at') if started_at is not None: started_at = iso8601.parse_date(started_at) - # TODO: what if it is not in UTC timezone? + # convert it to UTC and naive + started_at = started_at.astimezone(pytz.utc) + started_at = started_at.replace(tzinfo=None) customer = customer_model.get(customer_guid) if customer.company_guid != company.guid: diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 56ae3c0..bae80c3 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -93,6 +93,29 @@ def test_create_subscription_with_started_at(self): 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/', diff --git a/requirements.txt b/requirements.txt index e3052ee..022e5e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pyramid==1.4.3 waitress==0.8.6 pyramid_debugtoolbar==1.0.6 pyramid_tm==0.7 -iso8601==0.1.4 \ No newline at end of file +iso8601==0.1.4 +pytz==2013b \ No newline at end of file From 84f8190bfc4bc29db8cddd5f9aef9f61779b6b0a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 15:56:02 +0800 Subject: [PATCH 119/158] Add input validation for subscription view --- billy/api/subscription/forms.py | 44 +++++++++++++++++ billy/api/subscription/views.py | 25 +++++----- billy/api/utils.py | 38 ++++++++++++++ billy/tests/functional/test_subscription.py | 55 +++++++++++++++++++++ requirements.txt | 3 +- 5 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 billy/api/subscription/forms.py create mode 100644 billy/api/utils.py diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py new file mode 100644 index 0000000..51c11bb --- /dev/null +++ b/billy/api/subscription/forms.py @@ -0,0 +1,44 @@ +import pytz +import iso8601 +from wtforms import Form +from wtforms import TextField +from wtforms import DecimalField +from wtforms import Field +from wtforms import validators + + +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 SubscriptionCreateForm(Form): + customer_guid = TextField('Customer GUID', [ + validators.Required(), + # TODO: make sure the record exists? + ]) + plan_guid = TextField('Plan GUID', [ + validators.Required(), + # TODO: make sure the record exists? + ]) + 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(), + ]) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index e32c2f1..a0ebd2f 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -import pytz -import iso8601 import transaction as db_transaction from pyramid.view import view_config from pyramid.httpexceptions import HTTPNotFound @@ -11,6 +9,8 @@ from billy.models.plan import PlanModel from billy.models.subscription import SubscriptionModel from billy.api.auth import auth_api_key +from billy.api.utils import validate_form +from .forms import SubscriptionCreateForm @view_config(route_name='subscription_list', @@ -20,20 +20,17 @@ def subscription_list_post(request): """Create a new subscription """ - company = auth_api_key(request) model = SubscriptionModel(request.session) plan_model = PlanModel(request.session) customer_model = CustomerModel(request.session) - # TODO: do validation here - customer_guid = request.params['customer_guid'] - plan_guid = request.params['plan_guid'] - amount = request.params.get('amount') - started_at = request.params.get('started_at') - if started_at is not None: - started_at = iso8601.parse_date(started_at) - # convert it to UTC and naive - started_at = started_at.astimezone(pytz.utc) - started_at = started_at.replace(tzinfo=None) + + 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') + started_at = form.data.get('started_at') customer = customer_model.get(customer_guid) if customer.company_guid != company.guid: @@ -52,7 +49,7 @@ def subscription_list_post(request): model.yield_transactions([guid]) # TODO: process transactions right away? subscription = model.get(guid) - return subscription + return subscription @view_config(route_name='subscription', diff --git a/billy/api/utils.py b/billy/api/utils.py new file mode 100644 index 0000000..2a75d11 --- /dev/null +++ b/billy/api/utils.py @@ -0,0 +1,38 @@ +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) + validation_result = form.validate() + if not validation_result: + raise form_errors_to_bad_request(form.errors) + return form diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index bae80c3..f36dc96 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -63,6 +63,61 @@ def test_create_subscription(self): self.assertEqual(res.json['customer_guid'], customer_guid) self.assertEqual(res.json['plan_guid'], plan_guid) + def test_create_subscription_with_bad_parameters(self): + self.testapp.post( + '/v1/subscriptions/', + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='BAD_AMOUNT', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='-123.45', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + amount='0', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + self.testapp.post( + '/v1/subscriptions/', + dict( + customer_guid=self.customer_guid, + plan_guid=self.plan_guid, + started_at='BAD_DATETIME', + ), + extra_environ=dict(REMOTE_USER=self.api_key), + status=400, + ) + def test_create_subscription_with_started_at(self): customer_guid = self.customer_guid plan_guid = self.plan_guid diff --git a/requirements.txt b/requirements.txt index 022e5e1..95beb91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ waitress==0.8.6 pyramid_debugtoolbar==1.0.6 pyramid_tm==0.7 iso8601==0.1.4 -pytz==2013b \ No newline at end of file +pytz==2013b +WTForms==1.0.4 \ No newline at end of file From d95c80b3ba757e1722d78a2f516e6fbf28a5ea39 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 16:12:58 +0800 Subject: [PATCH 120/158] Add record existing validation for subscription view --- billy/api/subscription/forms.py | 10 ++- billy/api/utils.py | 19 +++++ billy/tests/functional/test_subscription.py | 90 +++++++++------------ 3 files changed, 64 insertions(+), 55 deletions(-) diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py index 51c11bb..3f41e1a 100644 --- a/billy/api/subscription/forms.py +++ b/billy/api/subscription/forms.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pytz import iso8601 from wtforms import Form @@ -6,6 +8,10 @@ from wtforms import Field from wtforms import validators +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 @@ -28,11 +34,11 @@ def process_formdata(self, valuelist): class SubscriptionCreateForm(Form): customer_guid = TextField('Customer GUID', [ validators.Required(), - # TODO: make sure the record exists? + RecordExistValidator(CustomerModel), ]) plan_guid = TextField('Plan GUID', [ validators.Required(), - # TODO: make sure the record exists? + RecordExistValidator(PlanModel), ]) amount = DecimalField('Amount', [ validators.Optional(), diff --git a/billy/api/utils.py b/billy/api/utils.py index 2a75d11..f969d56 100644 --- a/billy/api/utils.py +++ b/billy/api/utils.py @@ -32,7 +32,26 @@ def validate_form(form_cls, request): """ 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/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index f36dc96..6a553f1 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -64,59 +64,43 @@ def test_create_subscription(self): self.assertEqual(res.json['plan_guid'], plan_guid) def test_create_subscription_with_bad_parameters(self): - self.testapp.post( - '/v1/subscriptions/', - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) - self.testapp.post( - '/v1/subscriptions/', - dict( - customer_guid=self.customer_guid, - ), - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) - self.testapp.post( - '/v1/subscriptions/', - dict( - customer_guid=self.customer_guid, - plan_guid=self.plan_guid, - amount='BAD_AMOUNT', - ), - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) - self.testapp.post( - '/v1/subscriptions/', - dict( - customer_guid=self.customer_guid, - plan_guid=self.plan_guid, - amount='-123.45', - ), - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) - self.testapp.post( - '/v1/subscriptions/', - dict( - customer_guid=self.customer_guid, - plan_guid=self.plan_guid, - amount='0', - ), - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) - self.testapp.post( - '/v1/subscriptions/', - dict( - customer_guid=self.customer_guid, - plan_guid=self.plan_guid, - started_at='BAD_DATETIME', - ), - extra_environ=dict(REMOTE_USER=self.api_key), - status=400, - ) + 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 From a5b8e059d2a0e5e780fa529f504093ffef9d88e6 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 16:13:12 +0800 Subject: [PATCH 121/158] Remove unused import --- billy/scripts/process_transactions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/billy/scripts/process_transactions.py b/billy/scripts/process_transactions.py index 51f2004..1d530c9 100644 --- a/billy/scripts/process_transactions.py +++ b/billy/scripts/process_transactions.py @@ -13,7 +13,6 @@ from billy.models import setup_database from billy.models.subscription import SubscriptionModel from billy.models.transaction import TransactionModel -from billy.models.processors.balanced_payments import BalancedProcessor def usage(argv): From c14681ece124a01be801dcd84327a36d51f65e61 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 17:02:21 +0800 Subject: [PATCH 122/158] Add validation for all creating views --- billy/api/company/forms.py | 11 ++++ billy/api/company/views.py | 6 ++- billy/api/customer/forms.py | 11 ++++ billy/api/customer/views.py | 9 +++- billy/api/plan/forms.py | 45 ++++++++++++++++ billy/api/plan/views.py | 18 ++++--- billy/api/subscription/views.py | 8 +-- billy/tests/functional/test_company.py | 6 +++ billy/tests/functional/test_plan.py | 75 ++++++++++++++++++++++++++ 9 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 billy/api/company/forms.py create mode 100644 billy/api/customer/forms.py create mode 100644 billy/api/plan/forms.py 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 index 1f48b04..b283d4d 100644 --- a/billy/api/company/views.py +++ b/billy/api/company/views.py @@ -7,6 +7,8 @@ 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', @@ -26,9 +28,11 @@ 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 - processor_key = request.params['processor_key'] with db_transaction.manager: guid = model.create(processor_key=processor_key) company = model.get(guid) 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 index 3065232..df8223f 100644 --- a/billy/api/customer/views.py +++ b/billy/api/customer/views.py @@ -7,6 +7,8 @@ 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', @@ -27,10 +29,13 @@ def customer_list_post(request): """ 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 - external_id = request.params.get('external_id') - company_guid = company.guid with db_transaction.manager: guid = model.create( external_id=external_id, 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 index 02c2b26..6d7f7d3 100644 --- a/billy/api/plan/views.py +++ b/billy/api/plan/views.py @@ -7,6 +7,8 @@ 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', @@ -17,20 +19,22 @@ def plan_list_post(request): """ company = auth_api_key(request) - model = PlanModel(request.session) - # TODO: do validation here - plan_type = request.params['plan_type'] - amount = request.params['amount'] - frequency = request.params['frequency'] - interval = int(request.params.get('interval', 1)) + 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, diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index a0ebd2f..145c142 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -20,10 +20,6 @@ def subscription_list_post(request): """Create a new subscription """ - model = SubscriptionModel(request.session) - plan_model = PlanModel(request.session) - customer_model = CustomerModel(request.session) - company = auth_api_key(request) form = validate_form(SubscriptionCreateForm, request) @@ -32,6 +28,10 @@ def subscription_list_post(request): amount = form.data.get('amount') started_at = form.data.get('started_at') + model = SubscriptionModel(request.session) + plan_model = PlanModel(request.session) + customer_model = CustomerModel(request.session) + customer = customer_model.get(customer_guid) if customer.company_guid != company.guid: return HTTPForbidden('Can only subscribe to your own customer') diff --git a/billy/tests/functional/test_company.py b/billy/tests/functional/test_company.py index 9cc4625..ac1a864 100644 --- a/billy/tests/functional/test_company.py +++ b/billy/tests/functional/test_company.py @@ -25,6 +25,12 @@ def test_create_company(self): 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( diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index 9f7344a..0336c56 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -47,6 +47,81 @@ def test_create_plan(self): 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='0', + )) + 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_different_types(self): def assert_plan_type(plan_type): res = self.testapp.post( From 89705b7d6dc3f3588ce85876ba1d054c8c5a0ee5 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 17:30:39 +0800 Subject: [PATCH 123/158] Update read me --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2fe01c9..b33208e 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,69 @@ 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. +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 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 +``` From 2b8a2dc11e7cabb6b9c869cd5436c0e3756bb1f0 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 18:13:45 +0800 Subject: [PATCH 124/158] Update README.md --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b33208e..01adc95 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ process_billy_tx development.ini You can setup a crontab job to run the process_billy_tx periodically. -## Running Tests +## 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: @@ -72,3 +72,21 @@ 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 +``` From bf9b7d8560a03f3a8410158d5fc0797d1659f072 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 18:21:18 +0800 Subject: [PATCH 125/158] Cover a special case of plan creating view --- billy/tests/functional/test_plan.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/billy/tests/functional/test_plan.py b/billy/tests/functional/test_plan.py index 0336c56..58774bb 100644 --- a/billy/tests/functional/test_plan.py +++ b/billy/tests/functional/test_plan.py @@ -93,11 +93,6 @@ def assert_bad_parameters(params): frequency='weekly', amount='', )) - assert_bad_parameters(dict( - plan_type='charge', - frequency='weekly', - amount='0', - )) assert_bad_parameters(dict( plan_type='charge', frequency='weekly', @@ -122,6 +117,24 @@ def assert_bad_parameters(params): 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( From 99cebcc7e1762a226e6206aea629a152a9388a2f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 21:50:28 +0800 Subject: [PATCH 126/158] Make sure started_at of subscription model in past is not allowed --- billy/models/subscription.py | 8 +++-- .../unit/test_models/test_subscription.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 745522d..3616b36 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -52,9 +52,11 @@ def create( """ 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 = tables.now_func() - # TODO: should we allow a past started_at value? + 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, @@ -64,6 +66,8 @@ def create( 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() diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index 1adb327..d819a20 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -101,6 +101,29 @@ def test_create(self): 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 @@ -118,6 +141,16 @@ def test_create_with_started_at(self): 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) From aec3308fdc767325854bd1edd42904331481b0cd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Mon, 26 Aug 2013 21:58:36 +0800 Subject: [PATCH 127/158] Make suer started_at in past cannot pass API param validation --- billy/api/subscription/forms.py | 20 ++++++++++++++++++++ billy/tests/functional/test_subscription.py | 12 ++++++++++++ 2 files changed, 32 insertions(+) diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py index 3f41e1a..a7ed40e 100644 --- a/billy/api/subscription/forms.py +++ b/billy/api/subscription/forms.py @@ -8,6 +8,7 @@ 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 @@ -31,6 +32,24 @@ def process_formdata(self, valuelist): 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 SubscriptionCreateForm(Form): customer_guid = TextField('Customer GUID', [ validators.Required(), @@ -47,4 +66,5 @@ class SubscriptionCreateForm(Form): ]) started_at = ISO8601Field('Started at datetime', [ validators.Optional(), + NoPastValidator(), ]) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 6a553f1..d67355b 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -63,6 +63,18 @@ def test_create_subscription(self): self.assertEqual(res.json['customer_guid'], customer_guid) self.assertEqual(res.json['plan_guid'], plan_guid) + 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( From 332dc60779d65fbc37fd9d3d1e8b97d58b8bf13a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 18:31:21 +0800 Subject: [PATCH 128/158] Fix test imports cause error for pyramid scanning issue --- billy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/billy/__init__.py b/billy/__init__.py index 6483ce7..35413c7 100644 --- a/billy/__init__.py +++ b/billy/__init__.py @@ -23,5 +23,5 @@ def main(global_config, **settings): # provides api views config.include('.api') - config.scan() + config.scan(ignore=b'billy.tests') return config.make_wsgi_app() From 9614f40fc473dfdfba32612c13a1a9067c8cd504 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 23:48:16 +0800 Subject: [PATCH 129/158] Make database URL for unit testing be configurable --- billy/tests/unit/helper.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/billy/tests/unit/helper.py b/billy/tests/unit/helper.py index 3efb9be..74f8626 100644 --- a/billy/tests/unit/helper.py +++ b/billy/tests/unit/helper.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +import os import unittest import datetime @@ -17,9 +18,10 @@ def create_session(echo=False): from sqlalchemy.orm import sessionmaker from zope.sqlalchemy import ZopeTransactionExtension from billy.models.tables import DeclarativeBase - engine = create_engine('sqlite:///', convert_unicode=True, echo=echo) + 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(bind=engine) + DeclarativeBase.metadata.create_all() DBSession = scoped_session(sessionmaker( autocommit=False, @@ -40,4 +42,5 @@ def setUp(self): def tearDown(self): from billy.models import tables self.session.remove() + tables.DeclarativeBase.metadata.drop_all() tables.set_now_func(self._old_now_func) From 973dceceb6deb57adde42878add39547da2dcb51 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 23:49:21 +0800 Subject: [PATCH 130/158] Fix some major errors of unit tests with PostgreSQL --- billy/models/tables.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/billy/models/tables.py b/billy/models/tables.py index 468551c..7d1187c 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -59,9 +59,9 @@ class Company(DeclarativeBase): #: is this company deleted? deleted = Column(Boolean, default=False, nullable=False) #: the created datetime of this company - created_at = Column(DateTime(timezone=True), default=now_func) + created_at = Column(DateTime, default=now_func) #: the updated datetime of this company - updated_at = Column(DateTime(timezone=True), default=now_func) + updated_at = Column(DateTime, default=now_func) #: plans of this company plans = relationship('Plan', cascade='all, delete-orphan', backref='company') @@ -91,9 +91,9 @@ class Customer(DeclarativeBase): #: is this company deleted? deleted = Column(Boolean, default=False, nullable=False) #: the created datetime of this company - created_at = Column(DateTime(timezone=True), default=now_func) + created_at = Column(DateTime, default=now_func) #: the updated datetime of this company - updated_at = Column(DateTime(timezone=True), default=now_func) + updated_at = Column(DateTime, default=now_func) #: subscriptions of this customer subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='customer') @@ -123,7 +123,7 @@ class Plan(DeclarativeBase): #: a short name of this plan name = Column(Unicode(128)) #: a long description of this plan - description = Column(UnicodeText(1024)) + 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? @@ -136,9 +136,9 @@ class Plan(DeclarativeBase): #: is this plan deleted? deleted = Column(Boolean, default=False, nullable=False) #: the created datetime of this plan - created_at = Column(DateTime(timezone=True), default=now_func) + created_at = Column(DateTime, default=now_func) #: the updated datetime of this plan - updated_at = Column(DateTime(timezone=True), default=now_func) + updated_at = Column(DateTime, default=now_func) #: subscriptions of this plan subscriptions = relationship('Subscription', cascade='all, delete-orphan', backref='plan') @@ -180,17 +180,17 @@ class Subscription(DeclarativeBase): #: is this subscription canceled? canceled = Column(Boolean, default=False, nullable=False) #: the next datetime to charge or pay out - next_transaction_at = Column(DateTime(timezone=True), nullable=False) + 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(timezone=True), nullable=False) + started_at = Column(DateTime, nullable=False) #: the canceled datetime of this subscription - canceled_at = Column(DateTime(timezone=True), default=None) + canceled_at = Column(DateTime, default=None) #: the created datetime of this subscription - created_at = Column(DateTime(timezone=True), default=now_func) + created_at = Column(DateTime, default=now_func) #: the updated datetime of this subscription - updated_at = Column(DateTime(timezone=True), default=now_func) + updated_at = Column(DateTime, default=now_func) #: transactions of this subscription transactions = relationship('Transaction', cascade='all, delete-orphan', @@ -241,11 +241,11 @@ class Transaction(DeclarativeBase): #: error message when failed error_message = Column(UnicodeText) #: the scheduled datetime of this transaction should be processed - scheduled_at = Column(DateTime(timezone=True), default=now_func) + scheduled_at = Column(DateTime, default=now_func) #: the created datetime of this subscription - created_at = Column(DateTime(timezone=True), default=now_func) + created_at = Column(DateTime, default=now_func) #: the updated datetime of this subscription - updated_at = Column(DateTime(timezone=True), default=now_func) + updated_at = Column(DateTime, default=now_func) #: target transaction of refund transaction refund_to = relationship( From d07e75b401a46765837f5d30a3f064407d84c3d2 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 23:50:08 +0800 Subject: [PATCH 131/158] Make functional testing database configurable --- billy/models/__init__.py | 11 ++++------- billy/tests/functional/helper.py | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/billy/models/__init__.py b/billy/models/__init__.py index eee36ee..4b1d879 100644 --- a/billy/models/__init__.py +++ b/billy/models/__init__.py @@ -1,10 +1,13 @@ 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 + def setup_database(global_config, **settings): """Setup database @@ -21,11 +24,5 @@ def setup_database(global_config, **settings): bind=settings['engine'] )) - # SQLite does not support utc_timestamp function, therefore, we need to - # replace it with utcnow of datetime here - if settings['engine'].name == 'sqlite': - import datetime - from . import tables - tables.set_now_func(datetime.datetime.utcnow) - + tables.set_now_func(datetime.datetime.utcnow) return settings diff --git a/billy/tests/functional/helper.py b/billy/tests/functional/helper.py index ee7fcb0..b849304 100644 --- a/billy/tests/functional/helper.py +++ b/billy/tests/functional/helper.py @@ -1,5 +1,5 @@ from __future__ import unicode_literals - +import os import unittest @@ -12,15 +12,27 @@ def setUp(self): from billy.models.tables import DeclarativeBase # init database + db_url = os.environ.get('BILLY_FUNC_TEST_DB', 'sqlite://') settings = { - 'sqlalchemy.url': 'sqlite:///' + 'sqlalchemy.url': db_url } + if hasattr(ViewTestCase, '_engine'): + settings['engine'] = ViewTestCase._engine settings = setup_database({}, **settings) - DeclarativeBase.metadata.create_all(bind=settings['session'].get_bind()) + # 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() + From 8316951baa51eeef73236d3db724530c97eb59a4 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 16:11:21 +0000 Subject: [PATCH 132/158] Fix a test failure of subscription model --- billy/tests/unit/test_models/test_subscription.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index d819a20..0a9b634 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -468,12 +468,14 @@ def test_yield_transactions_with_multiple_period(self): 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(tx_dates, [ + 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) From 7e2a0491e784a6358b8f5ca298a54b10e4820129 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 16:24:58 +0000 Subject: [PATCH 133/158] Fix a trandaction model test failure --- billy/models/transaction.py | 1 + .../tests/unit/test_models/test_transaction.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index d31b366..47ef133 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -69,6 +69,7 @@ def list_by_company_guid(self, company_guid, offset=None, limit=None): 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) diff --git a/billy/tests/unit/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py index 98c2331..39eb9b5 100644 --- a/billy/tests/unit/test_models/test_transaction.py +++ b/billy/tests/unit/test_models/test_transaction.py @@ -166,14 +166,15 @@ def test_list_by_company_guid_with_offset_limit(self): guids = [] with db_transaction.manager: for i in range(10): - 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) + 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( From bab08111461d118a57d71bd9a2d57de113da9aed Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 16:28:17 +0000 Subject: [PATCH 134/158] Fix a transaction view test failure --- billy/tests/functional/test_transaction.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/billy/tests/functional/test_transaction.py b/billy/tests/functional/test_transaction.py index a7472c3..40fca8a 100644 --- a/billy/tests/functional/test_transaction.py +++ b/billy/tests/functional/test_transaction.py @@ -81,14 +81,15 @@ def test_transaction_list_by_company(self): guids = [self.transaction_guid] with db_transaction.manager: for i in range(9): - 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) + 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), From 270da99315bb17a7c066fe2794b3ca2783e816f3 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 13:42:02 +0800 Subject: [PATCH 135/158] Add Chef submodule --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitmodules 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 From f7f2c0dfbec84ab4b61d9797c68519a04ade3d07 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 18:12:22 +0800 Subject: [PATCH 136/158] Add vagrant file --- Vagrantfile | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 Vagrantfile diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..b4b7436 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,115 @@ +# -*- 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 :chef_solo do |chef| + chef.cookbooks_path = "chef/cookbooks" + chef.add_recipe "billy" + + # You may also specify custom JSON attributes: + # chef.json = { :mysql_password => "foo" } + 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 From 4628c870a9ee9a631e5da8811868e9da73fc3341 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 27 Aug 2013 20:27:59 +0800 Subject: [PATCH 137/158] Add password for postgresql database --- Vagrantfile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Vagrantfile b/Vagrantfile index b4b7436..dfff79d 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -86,8 +86,13 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| chef.cookbooks_path = "chef/cookbooks" chef.add_recipe "billy" - # You may also specify custom JSON attributes: - # chef.json = { :mysql_password => "foo" } + chef.json = { + :postgresql => { + :password => { + :postgres => "billie jean" + } + } + } end # Enable provisioning with chef server, specifying the chef server URL, From 9fbbed18f075cd3e789f65e6484ab93ce9bf39d8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 29 Aug 2013 12:57:03 +0800 Subject: [PATCH 138/158] Update vagrant file to solve a deploying issue caused by old Chef version --- Vagrantfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Vagrantfile b/Vagrantfile index dfff79d..541336c 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -82,6 +82,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| # 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" From d47e3b71a1b0f546b4ff39bb3de6e5c4e5dfccfe Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 29 Aug 2013 17:13:38 +0800 Subject: [PATCH 139/158] Fix balanced processor API key is not configured and return id rather than uri bug --- billy/models/processors/balanced_payments.py | 16 +++- .../tests/unit/test_models/test_processors.py | 82 ++++++++++++++----- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 5e1e0c6..fb5dc7b 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -26,14 +26,22 @@ def _to_cent(self, amount): return cent def create_customer(self, customer): + # TODO: what about thread safty issue? + 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.id + return record.uri def prepare_customer(self, customer, payment_uri=None): + # TODO: what about thread safty issue? + 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 @@ -66,8 +74,8 @@ def _do_transaction( method_name, extra_kwargs ): - api_key = transaction.subscription.plan.company.processor_key # TODO: what about thread safty issue? + api_key = transaction.subscription.plan.company.processor_key balanced.configure(api_key) # make sure we won't duplicate debit try: @@ -84,7 +92,7 @@ def _do_transaction( if record is not None: self.logger.warn('Balanced transaction record for %s already ' 'exist', transaction.guid) - return record.id + return record.uri # TODO: handle error here # get balanced customer record @@ -106,7 +114,7 @@ def _do_transaction( 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.id + return record.uri def charge(self, transaction): extra_kwargs = {} diff --git a/billy/tests/unit/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py index 827da82..323e691 100644 --- a/billy/tests/unit/test_models/test_processors.py +++ b/billy/tests/unit/test_models/test_processors.py @@ -62,11 +62,21 @@ def make_one(self, *args, **kwargs): 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(id='MOCK_BALANCED_CUSTOMER_ID') + flexmock(uri='MOCK_BALANCED_CUSTOMER_URI') .should_receive('save') .replace_with(lambda: mock_balanced_customer) .once() @@ -79,16 +89,26 @@ class BalancedCustomer(object): processor = self.make_one(customer_cls=BalancedCustomer) customer_id = processor.create_customer(customer) - self.assertEqual(customer_id, 'MOCK_BALANCED_CUSTOMER_ID') + 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_ID', + 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() @@ -100,12 +120,12 @@ def test_prepare_customer_with_card(self): # mock balanced.Customer class class BalancedCustomer(object): - def find(self, id): + def find(self, uri): pass ( flexmock(BalancedCustomer) .should_receive('find') - .with_args('MOCK_BALANCED_CUSTOMER_ID') + .with_args('MOCK_BALANCED_CUSTOMER_URI') .replace_with(lambda _: mock_balanced_customer) .once() ) @@ -114,13 +134,23 @@ def find(self, id): 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_ID', + 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() @@ -132,12 +162,12 @@ def test_prepare_customer_with_bank_account(self): # mock balanced.Customer class class BalancedCustomer(object): - def find(self, id): + def find(self, uri): pass ( flexmock(BalancedCustomer) .should_receive('find') - .with_args('MOCK_BALANCED_CUSTOMER_ID') + .with_args('MOCK_BALANCED_CUSTOMER_URI') .replace_with(lambda _: mock_balanced_customer) .once() ) @@ -149,7 +179,7 @@ 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_ID', + external_id='MOCK_BALANCED_CUSTOMER_URI', ) customer = self.customer_model.get(self.customer_guid) @@ -163,12 +193,12 @@ def test_prepare_customer_with_none_payment_uri(self): # mock balanced.Customer class class BalancedCustomer(object): - def find(self, id): + def find(self, uri): pass ( flexmock(BalancedCustomer) .should_receive('find') - .with_args('MOCK_BALANCED_CUSTOMER_ID') + .with_args('MOCK_BALANCED_CUSTOMER_URI') .replace_with(lambda _: mock_balanced_customer) .never() ) @@ -180,7 +210,7 @@ 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_ID', + external_id='MOCK_BALANCED_CUSTOMER_URI', ) customer = self.customer_model.get(self.customer_guid) @@ -189,12 +219,12 @@ def test_prepare_customer_with_bad_payment_uri(self): # mock balanced.Customer class class BalancedCustomer(object): - def find(self, id): + def find(self, uri): pass ( flexmock(BalancedCustomer) .should_receive('find') - .with_args('MOCK_BALANCED_CUSTOMER_ID') + .with_args('MOCK_BALANCED_CUSTOMER_URI') .replace_with(lambda _: mock_balanced_customer) .once() ) @@ -224,10 +254,18 @@ def _test_operation( transaction = tx_model.get(guid) self.customer_model.update( guid=transaction.subscription.customer_guid, - external_id='MOCK_BALANCED_CUSTOMER_ID', + 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(): @@ -256,7 +294,7 @@ class Resource(object): Resource.query = mock_query # mock balanced.RESOURCE instance - mock_resource = flexmock(id='MOCK_BALANCED_RESOURCE_ID') + mock_resource = flexmock(uri='MOCK_BALANCED_RESOURCE_URI') # mock balanced.Customer instance kwargs = dict( @@ -279,12 +317,12 @@ class Resource(object): # mock balanced.Customer class class BalancedCustomer(object): - def find(self, id): + def find(self, uri): pass ( flexmock(BalancedCustomer) .should_receive('find') - .with_args('MOCK_BALANCED_CUSTOMER_ID') + .with_args('MOCK_BALANCED_CUSTOMER_URI') .replace_with(lambda _: mock_balanced_customer) .once() ) @@ -295,7 +333,7 @@ def find(self, id): ) method = getattr(processor, processor_method_name) balanced_tx_id = method(transaction) - self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_RESOURCE_ID') + self.assertEqual(balanced_tx_id, 'MOCK_BALANCED_RESOURCE_URI') def _test_operation_with_created_record( self, @@ -315,7 +353,7 @@ def _test_operation_with_created_record( transaction = tx_model.get(guid) # mock balanced.RESOURCE instance - mock_resource = flexmock(id='MOCK_BALANCED_RESOURCE_ID') + mock_resource = flexmock(uri='MOCK_BALANCED_RESOURCE_URI') # mock result page object of balanced.RESOURCE.query.filter(...) mock_page = ( @@ -342,8 +380,8 @@ class Resource(object): processor = self.make_one(**{cls_name: Resource}) method = getattr(processor, processor_method_name) - balanced_res_id = method(transaction) - self.assertEqual(balanced_res_id, 'MOCK_BALANCED_RESOURCE_ID') + balanced_res_uri = method(transaction) + self.assertEqual(balanced_res_uri, 'MOCK_BALANCED_RESOURCE_URI') def test_charge(self): self._test_operation( From 60ff67a8f4c86bc2ace1c115aa3b4a0a17a5aeb8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 29 Aug 2013 23:34:06 +0800 Subject: [PATCH 140/158] Process transaction right away when subscription is created --- billy/api/subscription/views.py | 11 ++++- billy/models/transaction.py | 4 +- billy/request.py | 17 ++++++- billy/tests/functional/helper.py | 10 ++-- billy/tests/functional/test_subscription.py | 51 +++++++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index 145c142..219b61c 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -8,6 +8,7 @@ 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 .forms import SubscriptionCreateForm @@ -31,6 +32,7 @@ def subscription_list_post(request): 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: @@ -39,6 +41,7 @@ def subscription_list_post(request): 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, @@ -46,8 +49,12 @@ def subscription_list_post(request): amount=amount, started_at=started_at, ) - model.yield_transactions([guid]) - # TODO: process transactions right away? + 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 diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 47ef133..d7b1fda 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -197,7 +197,7 @@ def process_one(self, processor, transaction): transaction.guid, transaction.status, transaction.external_id) - def process_transactions(self, processor): + def process_transactions(self, processor, guids=None): """Process all transactions """ @@ -209,6 +209,8 @@ def process_transactions(self, processor): self.STATUS_RETRYING] )) ) + if guids is not None: + query = query.filter(Transaction.guid.in_(guids)) processed_transaction_guids = [] for transaction in query: diff --git a/billy/request.py b/billy/request.py index 6875472..045862d 100644 --- a/billy/request.py +++ b/billy/request.py @@ -2,6 +2,7 @@ from pyramid.request import Request from pyramid.decorator import reify +from pyramid.path import DottedNameResolver class APIRequest(Request): @@ -11,5 +12,17 @@ def session(self): """Session object for database operations """ - settions = self.registry.settings - return settions['session'] + 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/tests/functional/helper.py b/billy/tests/functional/helper.py index b849304..8bcdbe9 100644 --- a/billy/tests/functional/helper.py +++ b/billy/tests/functional/helper.py @@ -11,11 +11,14 @@ def setUp(self): 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 - } + settings['sqlalchemy.url'] = db_url if hasattr(ViewTestCase, '_engine'): settings['engine'] = ViewTestCase._engine settings = setup_database({}, **settings) @@ -35,4 +38,3 @@ def tearDown(self): from billy.models.tables import DeclarativeBase self.testapp.session.remove() DeclarativeBase.metadata.drop_all() - diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index d67355b..cd4e4b6 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -2,11 +2,27 @@ import datetime 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 + + @freeze_time('2013-08-16') class TestSubscriptionViews(ViewTestCase): @@ -14,6 +30,9 @@ 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) @@ -35,6 +54,9 @@ def setUp(self): 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' @@ -44,6 +66,27 @@ def test_create_subscription(self): 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( @@ -63,6 +106,14 @@ def test_create_subscription(self): self.assertEqual(res.json['customer_guid'], customer_guid) self.assertEqual(res.json['plan_guid'], plan_guid) + 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/', From 5abdb2b60507f9499755f2a56be47a7ab8a2eb9f Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 09:32:47 +0800 Subject: [PATCH 141/158] Clear race condition concern in balanced payment processor (thread local is used in Balanced-Python) --- billy/models/processors/balanced_payments.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index fb5dc7b..9b2b2ed 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -26,7 +26,6 @@ def _to_cent(self, amount): return cent def create_customer(self, customer): - # TODO: what about thread safty issue? api_key = customer.company.processor_key balanced.configure(api_key) @@ -38,7 +37,6 @@ def create_customer(self, customer): return record.uri def prepare_customer(self, customer, payment_uri=None): - # TODO: what about thread safty issue? api_key = customer.company.processor_key balanced.configure(api_key) @@ -74,7 +72,6 @@ def _do_transaction( method_name, extra_kwargs ): - # TODO: what about thread safty issue? api_key = transaction.subscription.plan.company.processor_key balanced.configure(api_key) # make sure we won't duplicate debit From 004d5f19088c07a87aa27d94194f575300b7daf7 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 09:48:25 +0800 Subject: [PATCH 142/158] Add missing payment URI for subscription API --- billy/api/subscription/forms.py | 3 +++ billy/api/subscription/views.py | 2 ++ billy/renderers.py | 1 + billy/tests/functional/test_subscription.py | 3 +++ 4 files changed, 9 insertions(+) diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py index a7ed40e..a3fa598 100644 --- a/billy/api/subscription/forms.py +++ b/billy/api/subscription/forms.py @@ -59,6 +59,9 @@ class SubscriptionCreateForm(Form): 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? diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index 219b61c..9284c7c 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -27,6 +27,7 @@ def subscription_list_post(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) @@ -47,6 +48,7 @@ def subscription_list_post(request): customer_guid=customer_guid, plan_guid=plan_guid, amount=amount, + payment_uri=payment_uri, started_at=started_at, ) tx_guids = model.yield_transactions([guid]) diff --git a/billy/renderers.py b/billy/renderers.py index 7c22893..75ca9ff 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -55,6 +55,7 @@ def subscription_adapter(subscription, request): 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(), diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index cd4e4b6..2d7e1c0 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -60,6 +60,7 @@ def test_create_subscription(self): 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 @@ -93,6 +94,7 @@ def mock_charge(transaction): customer_guid=customer_guid, plan_guid=plan_guid, amount=amount, + payment_uri=payment_uri, ), extra_environ=dict(REMOTE_USER=self.api_key), status=200, @@ -105,6 +107,7 @@ def mock_charge(transaction): 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']) From d9eb88af659cd202d8380578d9bb7420b2c3334c Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 09:51:20 +0800 Subject: [PATCH 143/158] Create card for basic integration test scenario --- billy/tests/integration/helper.py | 11 +++++++++-- billy/tests/integration/test_basic.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/billy/tests/integration/helper.py b/billy/tests/integration/helper.py index 500cc55..58dabf1 100644 --- a/billy/tests/integration/helper.py +++ b/billy/tests/integration/helper.py @@ -9,8 +9,15 @@ 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.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): diff --git a/billy/tests/integration/test_basic.py b/billy/tests/integration/test_basic.py index 04bc1ce..2e6883f 100644 --- a/billy/tests/integration/test_basic.py +++ b/billy/tests/integration/test_basic.py @@ -6,6 +6,19 @@ 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/', @@ -47,6 +60,7 @@ def test_simple_subscription(self): dict( customer_guid=customer['guid'], plan_guid=plan['guid'], + payment_uri=card.uri, ), headers=[self.make_auth(api_key)], status=200 From aabdf288abe438011bd368ea921ff4ff5dd4027d Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 12:43:49 +0800 Subject: [PATCH 144/158] Add transaction check in basic integration testing scenario --- billy/tests/integration/test_basic.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/billy/tests/integration/test_basic.py b/billy/tests/integration/test_basic.py index 2e6883f..2032401 100644 --- a/billy/tests/integration/test_basic.py +++ b/billy/tests/integration/test_basic.py @@ -69,4 +69,21 @@ def test_simple_subscription(self): self.assertEqual(subscription['customer_guid'], customer['guid']) self.assertEqual(subscription['plan_guid'], plan['guid']) - # TODO: check transaction here? + # 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? From 11b6b930099538f82dce5c4015e6724d291621b0 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 13:55:11 +0800 Subject: [PATCH 145/158] Add chef submodule folder --- chef | 1 + 1 file changed, 1 insertion(+) create mode 160000 chef diff --git a/chef b/chef new file mode 160000 index 0000000..89ddc99 --- /dev/null +++ b/chef @@ -0,0 +1 @@ +Subproject commit 89ddc992e43fc33aab15601914c8d547529acb12 From e38e82671bce43846fab3313be4636cf6b1ee0ea Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Tue, 3 Sep 2013 16:50:10 +0800 Subject: [PATCH 146/158] Fix SQLAlchemy Unicode warning --- billy/api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/billy/api/auth.py b/billy/api/auth.py index e795512..5f698b1 100644 --- a/billy/api/auth.py +++ b/billy/api/auth.py @@ -11,7 +11,7 @@ def auth_api_key(request): """ model = CompanyModel(request.session) - company = model.get_by_api_key(request.remote_user) + 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 From 50073b3f7c208ed5874c703c8a3013d00a357d67 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 08:24:20 +0800 Subject: [PATCH 147/158] Change meta tag naming of Balanced resource object --- billy/models/processors/balanced_payments.py | 4 ++-- billy/tests/integration/test_basic.py | 2 +- billy/tests/unit/test_models/test_processors.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 9b2b2ed..11d3dcd 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -78,7 +78,7 @@ def _do_transaction( try: record = ( resource_cls.query - .filter(**{'meta.billy_transaction_guid': transaction.guid}) + .filter(**{'meta.billy.transaction_guid': transaction.guid}) .one() ) except balanced.exc.NoResultFound: @@ -103,7 +103,7 @@ def _do_transaction( 'Generated by Billy from subscription {}, scheduled_at={}' .format(transaction.subscription.guid, transaction.scheduled_at) ), - meta=dict(billy_transaction_guid=transaction.guid), + meta={'billy.transaction_guid': transaction.guid}, ) kwargs.update(extra_kwargs) diff --git a/billy/tests/integration/test_basic.py b/billy/tests/integration/test_basic.py index 2032401..3b63235 100644 --- a/billy/tests/integration/test_basic.py +++ b/billy/tests/integration/test_basic.py @@ -82,7 +82,7 @@ def test_simple_subscription(self): self.assertEqual(transaction['status'], 'done') debit = balanced.Debit.find(transaction['external_id']) - self.assertEqual(debit.meta['billy_transaction_guid'], transaction['guid']) + self.assertEqual(debit.meta['billy.transaction_guid'], transaction['guid']) self.assertEqual(debit.amount, 1234) self.assertEqual(debit.status, 'succeeded') diff --git a/billy/tests/unit/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py index 323e691..29110ec 100644 --- a/billy/tests/unit/test_models/test_processors.py +++ b/billy/tests/unit/test_models/test_processors.py @@ -283,7 +283,7 @@ def mock_one(): mock_query = ( flexmock() .should_receive('filter') - .with_args(**{'meta.billy_transaction_guid': transaction.guid}) + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) .replace_with(lambda **kw: mock_page) .mock() ) @@ -299,7 +299,7 @@ class Resource(object): # mock balanced.Customer instance kwargs = dict( amount=int(transaction.amount * 100), - meta=dict(billy_transaction_guid=transaction.guid), + meta={'billy.transaction_guid': transaction.guid}, description=( 'Generated by Billy from subscription {}, scheduled_at={}' .format(transaction.subscription.guid, transaction.scheduled_at) @@ -368,7 +368,7 @@ def _test_operation_with_created_record( mock_query = ( flexmock() .should_receive('filter') - .with_args(**{'meta.billy_transaction_guid': transaction.guid}) + .with_args(**{'meta.billy.transaction_guid': transaction.guid}) .replace_with(lambda **kw: mock_page) .mock() ) From d134d1e344c64c023d628b56effeaddc6032580a Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 09:45:18 +0800 Subject: [PATCH 148/158] Make sure only charge transaction can be refunded --- billy/models/transaction.py | 5 ++--- .../tests/unit/test_models/test_transaction.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index d7b1fda..072c363 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -100,9 +100,8 @@ def create( 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_REFUND: - raise ValueError('Cannot set refund_to_guid to a refund ' - 'transaction') + if refund_transaction.transaction_type != self.TYPE_CHARGE: + raise ValueError('Only charge transaction can be refunded') now = tables.now_func() transaction = tables.Transaction( diff --git a/billy/tests/unit/test_models/test_transaction.py b/billy/tests/unit/test_models/test_transaction.py index 39eb9b5..af86466 100644 --- a/billy/tests/unit/test_models/test_transaction.py +++ b/billy/tests/unit/test_models/test_transaction.py @@ -365,6 +365,24 @@ def test_create_refund_with_wrong_target(self): 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) From c505294f3fa2890cf1b68b163d43bbc7269ac5c8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 09:46:14 +0800 Subject: [PATCH 149/158] Implement refund of processor --- billy/models/processors/balanced_payments.py | 36 +++++- billy/models/processors/base.py | 6 + .../tests/unit/test_models/test_processors.py | 105 ++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/billy/models/processors/balanced_payments.py b/billy/models/processors/balanced_payments.py index 11d3dcd..4976357 100644 --- a/billy/models/processors/balanced_payments.py +++ b/billy/models/processors/balanced_payments.py @@ -13,12 +13,14 @@ def __init__( 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 @@ -74,7 +76,7 @@ def _do_transaction( ): api_key = transaction.subscription.plan.company.processor_key balanced.configure(api_key) - # make sure we won't duplicate debit + # make sure we won't duplicate the transaction try: record = ( resource_cls.query @@ -134,3 +136,35 @@ def payout(self, transaction): 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 index d76dd10..d93e7cb 100644 --- a/billy/models/processors/base.py +++ b/billy/models/processors/base.py @@ -31,3 +31,9 @@ def payout(self, transaction): """ raise NotImplementedError + + def refund(self, transaction): + """Refund a transaction + + """ + raise NotImplementedError diff --git a/billy/tests/unit/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py index 29110ec..daea4f1 100644 --- a/billy/tests/unit/test_models/test_processors.py +++ b/billy/tests/unit/test_models/test_processors.py @@ -22,6 +22,8 @@ def test_base_processor(self): processor.charge(None) with self.assertRaises(NotImplementedError): processor.payout(None) + with self.assertRaises(NotImplementedError): + processor.refund(None) @freeze_time('2013-08-16') @@ -410,3 +412,106 @@ def test_payout_with_created_record(self): 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') From 9563d798148c084f5898f10cbba059a8046e22ef Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 10:19:36 +0800 Subject: [PATCH 150/158] Add test for refund of processor --- .../tests/unit/test_models/test_processors.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/billy/tests/unit/test_models/test_processors.py b/billy/tests/unit/test_models/test_processors.py index daea4f1..ac51431 100644 --- a/billy/tests/unit/test_models/test_processors.py +++ b/billy/tests/unit/test_models/test_processors.py @@ -515,3 +515,63 @@ def find(self, uri): ) 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') From b12cfac819380666481cf812e411063f4478df78 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 11:41:28 +0800 Subject: [PATCH 151/158] Fix prorated refund rate bug (the refund rate was inverted) --- billy/models/subscription.py | 2 +- .../unit/test_models/test_subscription.py | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 3616b36..2748a3b 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -130,7 +130,7 @@ def cancel(self, guid, prorated_refund=False): # TODO: what about calculate in different granularity here? # such as day or hour granularity? - rate = elapsed_seconds / total_seconds + rate = 1 - (elapsed_seconds / total_seconds) amount = previous_transaction.amount * rate amount = round_down_cent(amount) diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index 0a9b634..f1bfc5e 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -264,8 +264,10 @@ def test_subscription_cancel_with_prorated_refund(self): ) tx_guids = model.yield_transactions() - # 15 / 30 days, the rate should be 0.5 - with freeze_time('2013-06-16'): + # 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) @@ -273,7 +275,7 @@ def test_subscription_cancel_with_prorated_refund(self): 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')) + self.assertEqual(transaction.amount, decimal.Decimal('8')) def test_subscription_cancel_with_prorated_refund_and_amount(self): from billy.models.transaction import TransactionModel @@ -289,15 +291,17 @@ def test_subscription_cancel_with_prorated_refund_and_amount(self): ) model.yield_transactions() - # 15 / 30 days, the rate should be 0.5 - with freeze_time('2013-06-16'): + # 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('50')) + self.assertEqual(transaction.amount, decimal.Decimal('80')) def test_subscription_cancel_with_prorated_refund_rounding(self): from billy.models.transaction import TransactionModel @@ -312,13 +316,14 @@ def test_subscription_cancel_with_prorated_refund_rounding(self): ) model.yield_transactions() - # 17 / 30 days, the rate should be 0.56666... + # 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('5.66')) + self.assertEqual(transaction.amount, decimal.Decimal('4.33')) def test_subscription_cancel_with_zero_refund(self): model = self.make_one(self.session) From 72c3823705f4b5f2117d0b67fa78c1b6926c9bbd Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 11:46:51 +0800 Subject: [PATCH 152/158] Fix subscription zero prorated refund test failure --- billy/tests/unit/test_models/test_subscription.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index f1bfc5e..c0d9ee4 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -335,6 +335,9 @@ def test_subscription_cancel_with_zero_refund(self): 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) From e6d3ddae1d61441eec7fa30212d1e4f655311cf9 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 12:20:14 +0800 Subject: [PATCH 153/158] Implement subscription cancel --- billy/api/subscription/__init__.py | 2 + billy/api/subscription/forms.py | 7 ++ billy/api/subscription/views.py | 53 ++++++++-- billy/models/transaction.py | 7 +- billy/renderers.py | 4 + billy/tests/functional/test_subscription.py | 110 ++++++++++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) diff --git a/billy/api/subscription/__init__.py b/billy/api/subscription/__init__.py index 279ffe8..9f92738 100644 --- a/billy/api/subscription/__init__.py +++ b/billy/api/subscription/__init__.py @@ -3,4 +3,6 @@ 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 index a3fa598..9649921 100644 --- a/billy/api/subscription/forms.py +++ b/billy/api/subscription/forms.py @@ -5,6 +5,7 @@ from wtforms import Form from wtforms import TextField from wtforms import DecimalField +from wtforms import BooleanField from wtforms import Field from wtforms import validators @@ -71,3 +72,9 @@ class SubscriptionCreateForm(Form): validators.Optional(), NoPastValidator(), ]) + + +class SubscriptionCancelForm(Form): + prorated_refund = BooleanField('Prorated refund', [ + validators.Optional(), + ], default=False) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index 9284c7c..934a2a5 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -2,6 +2,7 @@ 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 @@ -12,6 +13,21 @@ from billy.api.auth import auth_api_key from billy.api.utils import validate_form 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', @@ -69,12 +85,37 @@ def subscription_get(request): """ company = auth_api_key(request) - model = SubscriptionModel(request.session) 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)) + + model = SubscriptionModel(request.session) + tx_model = TransactionModel(request.session) + get_and_check_subscription(request, company, guid) + + with db_transaction.manager: + tx_guid = model.cancel(guid, prorated_refund=prorated_refund) + if tx_guid is not None: + with db_transaction.manager: + tx_model.process_transactions(request.processor, [tx_guid]) + subscription = model.get(guid) - if subscription is None: - return HTTPNotFound('No such subscription {}'.format(guid)) - if subscription.customer.company_guid != company.guid: - return HTTPForbidden('You have no permission to access subscription {}' - .format(guid)) return subscription diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 072c363..83a7e14 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -162,9 +162,12 @@ def process_one(self, processor, transaction): if transaction.transaction_type == self.TYPE_CHARGE: method = processor.charge - else: + elif transaction.transaction_type == self.TYPE_PAYOUT: method = processor.payout - # TODO: support refund here + elif transaction.transaction_type == self.TYPE_REFUND: + method = processor.refund + else: + raise RuntimeError('Invalid transaction type to process') transaction_id = method(transaction) # TODO: generate an invoice here? diff --git a/billy/renderers.py b/billy/renderers.py index 75ca9ff..e40c15f 100644 --- a/billy/renderers.py +++ b/billy/renderers.py @@ -52,6 +52,9 @@ def plan_adapter(plan, request): 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), @@ -62,6 +65,7 @@ def subscription_adapter(subscription, request): 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, ) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 2d7e1c0..16ff72c 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import datetime +import decimal import transaction as db_transaction from flexmock import flexmock @@ -22,6 +23,9 @@ def charge(self, transaction): def payout(self, transaction): pass + def refund(self, transaction): + pass + @freeze_time('2013-08-16') class TestSubscriptionViews(ViewTestCase): @@ -102,6 +106,7 @@ def mock_charge(transaction): 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) @@ -368,3 +373,108 @@ def test_create_subscription_to_other_company_plan(self): 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_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, + ) + 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])) From da53f7d7b5d1888b50500cc0a93313512e01a384 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 12:31:53 +0800 Subject: [PATCH 154/158] Add test for making sure cancel subscription API will not be accessed without permission --- billy/tests/functional/test_subscription.py | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 16ff72c..0ed4918 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -406,6 +406,30 @@ def test_cancel_subscription(self): 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 From b0d22c29a78739bb18eb0a34246ae21d0c265f50 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 12:41:37 +0800 Subject: [PATCH 155/158] Remove unnecessary type check in process_one of transaction model --- billy/models/transaction.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 83a7e14..5331b48 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -166,8 +166,6 @@ def process_one(self, processor, transaction): method = processor.payout elif transaction.transaction_type == self.TYPE_REFUND: method = processor.refund - else: - raise RuntimeError('Invalid transaction type to process') transaction_id = method(transaction) # TODO: generate an invoice here? From b1913f5861dad0baa747d5a769c1b3df518cb1b8 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 21:58:16 +0800 Subject: [PATCH 156/158] Add refund_amount argument to cancel method of subscription model --- billy/models/subscription.py | 51 ++++++++++++------- .../unit/test_models/test_subscription.py | 43 +++++++++++++++- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 2748a3b..47f8213 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -92,15 +92,22 @@ def update(self, guid, **kwargs): self.session.add(subscription) self.session.flush() - def cancel(self, guid, prorated_refund=False): + 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') + subscription = self.get(guid, raise_error=True) if subscription.canceled: raise SubscriptionCanceledError('Subscription {} is already ' @@ -111,28 +118,38 @@ def cancel(self, guid, prorated_refund=False): tx_guid = None # 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 and subscription.period: + 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() ) - 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) + 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)) tx_model = TransactionModel(self.session) # make sure we will not refund zero dollar diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index c0d9ee4..5986b36 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -277,7 +277,48 @@ def test_subscription_cancel_with_prorated_refund(self): self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND) self.assertEqual(transaction.amount, decimal.Decimal('8')) - def test_subscription_cancel_with_prorated_refund_and_amount(self): + def test_subscription_cancel_with_wrong_arguments(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.yield_transactions() + + # 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=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() + + # 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) From b3e9d31b4b4183d7e9f09a492207e13094fb810e Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 23:05:39 +0800 Subject: [PATCH 157/158] Implement cancel subscription with refund_amount --- billy/api/subscription/forms.py | 19 ++++ billy/api/subscription/views.py | 22 +++- billy/models/subscription.py | 8 ++ billy/tests/functional/test_subscription.py | 107 ++++++++++++++++++ .../unit/test_models/test_subscription.py | 2 +- 5 files changed, 156 insertions(+), 2 deletions(-) diff --git a/billy/api/subscription/forms.py b/billy/api/subscription/forms.py index 9649921..b566781 100644 --- a/billy/api/subscription/forms.py +++ b/billy/api/subscription/forms.py @@ -51,6 +51,19 @@ def __call__(self, form, field): 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(), @@ -78,3 +91,9 @@ 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 index 934a2a5..f90f46b 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -12,6 +12,7 @@ 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 @@ -106,13 +107,32 @@ def subscription_cancel(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)] + )) + with db_transaction.manager: - tx_guid = model.cancel(guid, prorated_refund=prorated_refund) + 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]) diff --git a/billy/models/subscription.py b/billy/models/subscription.py index 47f8213..ab01b0c 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -116,6 +116,9 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): 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 ( @@ -128,6 +131,9 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): .order_by(tables.Transaction.scheduled_at.desc()) .first() ) + do_refund = True + + if do_refund: if prorated_refund: previous_datetime = previous_transaction.scheduled_at # the total time delta in the period @@ -163,6 +169,8 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): refund_to_guid=previous_transaction.guid, ) + # TODO: cancel not done transactions here + self.session.add(subscription) self.session.flush() return tx_guid diff --git a/billy/tests/functional/test_subscription.py b/billy/tests/functional/test_subscription.py index 0ed4918..9edff7a 100644 --- a/billy/tests/functional/test_subscription.py +++ b/billy/tests/functional/test_subscription.py @@ -442,6 +442,7 @@ def test_cancel_subscription_with_prorated_refund(self): 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, @@ -502,3 +503,109 @@ def mock_refund(transaction): ) 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/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index 5986b36..42ffc40 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -294,7 +294,7 @@ def test_subscription_cancel_with_wrong_arguments(self): # we should not allow refunding amount that grather than original # subscription amount with self.assertRaises(ValueError): - model.cancel(guid, refund_amount=10.01) + model.cancel(guid, refund_amount=decimal.Decimal('10.01')) def test_subscription_cancel_with_refund_amount(self): from billy.models.transaction import TransactionModel From 8a9237e741c08bfd62682d9e1630eb8419f2b552 Mon Sep 17 00:00:00 2001 From: Victor Lin Date: Thu, 5 Sep 2013 23:48:46 +0800 Subject: [PATCH 158/158] Mark not finished transactions as canceled when canceling a subscription --- billy/api/subscription/views.py | 2 + billy/models/subscription.py | 24 +++++- billy/models/tables.py | 2 +- billy/models/transaction.py | 3 + .../unit/test_models/test_subscription.py | 74 ++++++++++++++++++- 5 files changed, 98 insertions(+), 7 deletions(-) diff --git a/billy/api/subscription/views.py b/billy/api/subscription/views.py index f90f46b..d550289 100644 --- a/billy/api/subscription/views.py +++ b/billy/api/subscription/views.py @@ -127,6 +127,8 @@ def subscription_cancel(request): 'subscription amount {}'.format(amount)] )) + # TODO: make sure the subscription is not already canceled + with db_transaction.manager: tx_guid = model.cancel( guid, diff --git a/billy/models/subscription.py b/billy/models/subscription.py index ab01b0c..b512e3b 100644 --- a/billy/models/subscription.py +++ b/billy/models/subscription.py @@ -108,6 +108,8 @@ def cancel(self, guid, prorated_refund=False, refund_amount=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 ' @@ -131,7 +133,10 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): .order_by(tables.Transaction.scheduled_at.desc()) .first() ) - do_refund = True + # 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: @@ -157,7 +162,6 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): 'subscription amount {}' .format(previous_transaction.amount)) - tx_model = TransactionModel(self.session) # make sure we will not refund zero dollar # TODO: or... should we? if amount: @@ -169,7 +173,21 @@ def cancel(self, guid, prorated_refund=False, refund_amount=None): refund_to_guid=previous_transaction.guid, ) - # TODO: cancel not done transactions here + # 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() diff --git a/billy/models/tables.py b/billy/models/tables.py index 7d1187c..62bce65 100644 --- a/billy/models/tables.py +++ b/billy/models/tables.py @@ -230,7 +230,7 @@ class Transaction(DeclarativeBase): #: 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 + # 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) diff --git a/billy/models/transaction.py b/billy/models/transaction.py index 5331b48..c4ab52a 100644 --- a/billy/models/transaction.py +++ b/billy/models/transaction.py @@ -28,12 +28,15 @@ class TransactionModel(object): 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): diff --git a/billy/tests/unit/test_models/test_subscription.py b/billy/tests/unit/test_models/test_subscription.py index 42ffc40..a6f6365 100644 --- a/billy/tests/unit/test_models/test_subscription.py +++ b/billy/tests/unit/test_models/test_subscription.py @@ -251,6 +251,52 @@ def test_subscription_cancel(self): 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) @@ -263,6 +309,10 @@ def test_subscription_cancel_with_prorated_refund(self): 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 @@ -278,14 +328,20 @@ def test_subscription_cancel_with_prorated_refund(self): 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, ) - model.yield_transactions() + 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 @@ -307,6 +363,10 @@ def test_subscription_cancel_with_refund_amount(self): 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: @@ -330,7 +390,11 @@ def test_subscription_cancel_with_prorated_refund_and_amount_overwrite(self): plan_guid=self.monthly_plan_guid, amount=100, ) - model.yield_transactions() + 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 @@ -355,7 +419,11 @@ def test_subscription_cancel_with_prorated_refund_rounding(self): customer_guid=self.customer_tom_guid, plan_guid=self.monthly_plan_guid, ) - model.yield_transactions() + 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...