diff --git a/.gitignore b/.gitignore index 2080a90..3282aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ masonite_billing.egg-info .vscode .cache .env +dist +.pytest_cache **.pyc \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index a2a4180..5eb3144 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,8 +9,8 @@ services: - mysql before_install: -- mysql -u root --password="" < tests/travis.sql - pip install -r requirements.txt +- orator migrate -p tests/migrations -c config/database.py -f - pip install -e . script: travis_retry pytest diff --git a/billing/drivers/BillingStripeDriver.py b/billing/drivers/BillingStripeDriver.py index d555eef..def6e90 100644 --- a/billing/drivers/BillingStripeDriver.py +++ b/billing/drivers/BillingStripeDriver.py @@ -9,6 +9,7 @@ except ImportError: raise ImportError('Billing configuration found') + class BillingStripeDriver: _subscription_args = {} @@ -20,19 +21,25 @@ def subscribe(self, plan, token, customer=None, **kwargs): try: subscription = self._create_subscription(customer, - plan=plan, - **kwargs - ) + plan=plan, + **kwargs + ) return subscription except InvalidRequestError as e: if 'No such plan' in str(e): - raise PlanNotFound('The {0} plan was not found in Stripe'.format(plan)) + raise PlanNotFound( + 'The {0} plan was not found in Stripe'.format(plan)) if 'No such customer' in str(e): return False - + return None + def coupon(self, coupon_id): + self._subscription_args.update({'coupon': coupon_id}) + + return self + def trial(self, days=0): self._subscription_args.update({'trial_period_days': days}) return self @@ -41,15 +48,15 @@ def on_trial(self, plan_id=None): try: if plan_id: subscription = self._get_subscription(plan_id) - + if subscription['status'] == 'trialing': return True return False - + except InvalidRequestError: return False return None - + def is_subscribed(self, plan_id, plan_name=None): try: # get the plan @@ -57,15 +64,15 @@ def is_subscribed(self, plan_id, plan_name=None): if not plan_name: if subscription['status'] in ('active', 'trialing'): return True - + if subscription["items"]["data"][0]['plan']['id'] == plan_name: return True except InvalidRequestError: return False - + return False - + def is_canceled(self, plan_id): try: # get the plan @@ -74,27 +81,29 @@ def is_canceled(self, plan_id): return True except InvalidRequestError: return False - + return False - + def cancel(self, plan_id, now=False): subscription = stripe.Subscription.retrieve(plan_id) - if subscription.delete(at_period_end= not now): + if subscription.delete(at_period_end=not now): return subscription return False def create_customer(self, description, token): - return self._create_customer('test-customer', 'tok_amex') + return self._create_customer(description, token) def skip_trial(self): self._subscription_args.update({'trial_end': 'now'}) return self - + def charge(self, amount, **kwargs): if not kwargs.get('currency'): kwargs.update({'currency': billing.DRIVERS['stripe']['currency']}) + amount = self._apply_coupon(amount) + charge = stripe.Charge.create( amount=amount, **kwargs @@ -104,46 +113,61 @@ def charge(self, amount, **kwargs): return True else: return False - + def card(self, customer_id, token): stripe.Customer.modify(customer_id, - source=token, - ) + source=token, + ) return True def swap(self, plan, new_plan, **kwargs): subscription = stripe.Subscription.retrieve(plan) subscription = stripe.Subscription.modify(plan, - cancel_at_period_end=False, - items=[{ - 'id': subscription['items']['data'][0].id, - 'plan': new_plan, - }] - ) + cancel_at_period_end=False, + items=[{ + 'id': subscription['items']['data'][0].id, + 'plan': new_plan, + }] + ) return subscription def resume(self, plan_id): subscription = stripe.Subscription.retrieve(plan_id) stripe.Subscription.modify(plan_id, - cancel_at_period_end = False, - items=[{ - 'id': subscription['items']['data'][0].id - }] - ) + cancel_at_period_end=False, + items=[{ + 'id': subscription['items']['data'][0].id + }] + ) return True - + def plan(self, plan_id): subscription = self._get_subscription(plan_id) return subscription['plan']['name'] + def _apply_coupon(self, amount): + if 'coupon' in self._subscription_args: + if type(self._subscription_args['coupon']) == str: + coupon = stripe.Coupon.retrieve( + self._subscription_args['coupon']) + if coupon['percent_off']: + return abs((amount * (coupon['percent_off'] / 100)) - amount) + + return amount - coupon['amount_off'] + elif type(self._subscription_args['coupon']) == int: + return amount - self._subscription_args['coupon'] + elif type(self._subscription_args['coupon']) == float: + return abs((amount * (self._subscription_args['coupon'])) - amount) + + return amount def _create_customer(self, description, token): return stripe.Customer.create( description=description, - source=token # obtained with Stripe.js + source=token # obtained with Stripe.js ) - + def _create_subscription(self, customer, **kwargs): if not isinstance(customer, str): customer = customer['id'] @@ -157,6 +181,6 @@ def _create_subscription(self, customer, **kwargs): ) self._subscription_args = {} return subscription - - def _get_subscription(self, plan_id): - return stripe.Subscription.retrieve(plan_id) \ No newline at end of file + + def _get_subscription(self, plan_id): + return stripe.Subscription.retrieve(plan_id) diff --git a/billing/models/Billable.py b/billing/models/Billable.py index e2103ed..b666b6c 100644 --- a/billing/models/Billable.py +++ b/billing/models/Billable.py @@ -7,6 +7,7 @@ except ImportError: raise ImportError('No configuration file found') + class Billable: _processor = PROCESSOR @@ -17,7 +18,7 @@ def subscribe(self, processor_plan, token): """ if not self.customer_id: self.create_customer('Customer {0}'.format(self.email), token) - + if self.is_subscribed(processor_plan): return True @@ -34,7 +35,14 @@ def subscribe(self, processor_plan, token): self._save_subscription_model(processor_plan, subscription) return True - + + def coupon(self, coupon_id): + """ + Add coupon to subscription + """ + self._processor.coupon(coupon_id) + return self + def trial(self, days=False): """ Put user on trial @@ -51,14 +59,14 @@ def on_trial(self, plan_id=None): if not subscription: return False - + if not plan_id: if subscription.trial_ends_at and subscription.trial_ends_at.is_future(): return True - + if subscription.plan == plan_id and subscription.trial_ends_at and subscription.trial_ends_at.is_future(): return True - + return False def cancel(self, now=False): @@ -82,12 +90,12 @@ def cancel(self, now=False): subscription.save() return True return False - + def plan(self): subscription = self._get_subscription() if subscription: return subscription.plan_name - + return None def create_customer(self, description, token): @@ -95,7 +103,7 @@ def create_customer(self, description, token): self.customer_id = customer['id'] self.save() return self.customer_id - + def quantity(self, quantity): """ Set a quantity amount for a subscription @@ -112,10 +120,10 @@ def charge(self, amount, **kwargs): else: kwargs.update({'source': kwargs.get('token')}) del kwargs['token'] - + if not kwargs.get('description'): kwargs.update({'description': 'Charge For {0}'.format(self.email)}) - + return self._processor.charge(amount, **kwargs) """ Checking Subscription Status """ @@ -126,7 +134,7 @@ def on_grace_period(self): TODO """ pass - + def is_subscribed(self, plan_name=None): """ Check if a user is subscribed @@ -137,9 +145,9 @@ def is_subscribed(self, plan_name=None): # If the subscription does not expire OR the subscription ends at a time in the future if not self._get_subscription().ends_at or ( self._get_subscription().ends_at and self._get_subscription().ends_at.is_future()): # If the plan name equals the plan name specified - if plan_name and self._get_subscription().plan == plan_name: + if plan_name and self._get_subscription().plan == plan_name: return True - + # if the plan name was left out if not plan_name: return True @@ -155,7 +163,7 @@ def was_subscribed(self, plan=None): elif not plan: return True - + return False def is_canceled(self): @@ -184,7 +192,7 @@ def swap(self, new_plan, **kwargs): if swapped_subscription['trial_end']: trial_ends_at = pendulum.from_timestamp(swapped_subscription['trial_end']) - + if swapped_subscription['current_period_end']: ends_at = pendulum.from_timestamp(swapped_subscription['current_period_end']) @@ -194,22 +202,22 @@ def swap(self, new_plan, **kwargs): subscription.trial_ends_at = trial_ends_at subscription.ends_at = ends_at return subscription.save() - - + + def skip_trial(self): """ Skip any trial that the plan may have and charge the user """ self._processor.skip_trial() return self - + def prorate(self, bool): """ Whether the user should be prorated or not TODO: should be only parameters """ pass - + def resume(self): """ Resume a trial @@ -220,23 +228,23 @@ def resume(self): subscription.save() return plan - + def card(self, token): """ Change the card or token """ return self._processor.card(self.customer_id, token) - + def _get_subscription(self): return Subscription.where('user_id', self.id).first() - - def _save_subscription_model(self, processor_plan, subscription_object): + + def _save_subscription_model(self, processor_plan, subscription_object): trial_ends_at = None ends_at = None if subscription_object['trial_end']: trial_ends_at = pendulum.from_timestamp(subscription_object['trial_end']) - + if subscription_object['ended_at']: ends_at = pendulum.from_timestamp(subscription_object['ended_at']) @@ -260,4 +268,4 @@ def _save_subscription_model(self, processor_plan, subscription_object): ends_at = ends_at, ) - return subscription \ No newline at end of file + return subscription diff --git a/config/database.py b/config/database.py index 3640662..0cd2f23 100644 --- a/config/database.py +++ b/config/database.py @@ -30,12 +30,8 @@ DATABASES = { 'default': { - 'driver': os.environ.get('DB_DRIVER'), - 'host': os.environ.get('DB_HOST'), - 'database': os.environ.get('DB_DATABASE'), - 'user': os.environ.get('DB_USERNAME'), - 'password': os.environ.get('DB_PASSWORD'), - 'prefix': '' + 'driver': 'sqlite', + 'database': 'sqlite.db', } } diff --git a/requirements.txt b/requirements.txt index 6361af3..3ad808f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ pytest python-dotenv -pymysql \ No newline at end of file +orator diff --git a/setup.py b/setup.py index b343a9d..03ef725 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="masonite-billing", - version='0.1.2', + version='0.1.4', packages=[ 'billing', 'billing.commands', diff --git a/tests/migrations/2018_11_24_023016_make_subscriptions_table.py b/tests/migrations/2018_11_24_023016_make_subscriptions_table.py new file mode 100644 index 0000000..b91d1d4 --- /dev/null +++ b/tests/migrations/2018_11_24_023016_make_subscriptions_table.py @@ -0,0 +1,24 @@ +from orator.migrations import Migration + + +class MakeSubscriptionsTable(Migration): + + def up(self): + """ + Run the migrations. + """ + with self.schema.create('subscriptions') as table: + table.increments('id') + table.integer('user_id').unsigned() + table.string('plan') + table.string('plan_id') + table.string('plan_name') + table.timestamp('trial_ends_at').nullable() + table.timestamp('ends_at').nullable() + table.timestamps() + + def down(self): + """ + Revert the migrations. + """ + self.schema.drop('subscriptions') diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_stripe_billing.py b/tests/test_stripe_billing.py index 28beb88..160e212 100644 --- a/tests/test_stripe_billing.py +++ b/tests/test_stripe_billing.py @@ -19,6 +19,7 @@ def save(self): pass user = User() +user.email = "test@email.com" if os.environ.get('STRIPE_CUSTOMER'): user.customer_id = os.getenv('STRIPE_CUSTOMER') @@ -222,3 +223,21 @@ def test_subscription_is_over(): user.cancel(now=True) if os.environ.get('TEST_ENVIRONMENT') == 'travis': time.sleep(2) + +def test_can_use_coupon_on_charge(): + assert user._processor._apply_coupon(1000) == 1000 + assert user.coupon('5-off')._processor._apply_coupon(500) == 400 + assert user.coupon('10-percent-off')._processor._apply_coupon(1000) == 900 + assert user.coupon('10-percent-off')._processor._apply_coupon(1499) == 1349.1 + assert user.coupon(.10)._processor._apply_coupon(1499) == 1349.1 + assert user.coupon(100)._processor._apply_coupon(1000) == 900 + + +def test_can_use_coupon_on_subscription(): + user.skip_trial().coupon('5-off').subscribe('masonite-test', 'tok_amex') + assert user.is_subscribed() is True + assert user.on_trial() is False + subscription = user._get_subscription() + + user.cancel(now=True) +