diff --git a/.gitignore b/.gitignore index 85b9fc4c..2ded233a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ *.py[cod] /dist /*.egg-info +.idea diff --git a/localstripe/resources.py b/localstripe/resources.py index 53d88dbf..15964c5b 100644 --- a/localstripe/resources.py +++ b/localstripe/resources.py @@ -1102,7 +1102,7 @@ def __init__(self, type, data): self.type = type self.data = {'object': data._export()} - self.api_version = '2017-08-15' + self.api_version = '2025-12-15.clover' @classmethod def _api_create(cls, **data): @@ -1404,7 +1404,9 @@ def _get_next_invoice(cls, customer=None, subscription=None, if subscription_items is not None: assert type(subscription_items) is list for si in subscription_items: - assert type(si.get('plan')) is str + assert \ + type(si.get('plan')) is str or \ + type(si.get('price')) is str si['tax_rates'] = si.get('tax_rates', []) assert type(si['tax_rates']) is list assert all(type(tr) is str for tr in si['tax_rates']) @@ -1425,7 +1427,12 @@ def _get_next_invoice(cls, customer=None, subscription=None, customer_obj = Customer._api_retrieve(customer) if subscription_items: for si in subscription_items: - Plan._api_retrieve(si['plan']) # to return 404 if not existant + if 'plan' in si: + # to return 404 if not existant + Plan._api_retrieve(si['plan']) + if 'price' in si: + # to return 404 if not existant + Price._api_retrieve(si['price']) # To return 404 if not existant: if len(si['tax_rates']): [TaxRate._api_retrieve(tr) for tr in si['tax_rates']] @@ -1467,17 +1474,25 @@ def _get_next_invoice(cls, customer=None, subscription=None, items = subscription_items or \ (current_subscription and current_subscription.items._list) or [] for si in items: - if subscription_items is not None: + plan = None + price = None + if subscription_items is not None and si.get('plan'): plan = Plan._api_retrieve(si['plan']) quantity = si.get('quantity', 1) tax_rates = si['tax_rates'] + elif subscription_items is not None and si.get('price'): + price = Price._api_retrieve(si['price']) + quantity = si.get('quantity', 1) + tax_rates = si['tax_rates'] else: plan = si.plan + price = si.price quantity = si.quantity tax_rates = [tr.id for tr in si.tax_rates] invoice_items.append( SubscriptionItem(subscription=subscription, - plan=plan.id, + plan=plan.id if plan else None, + price=price.id if price else None, quantity=quantity, tax_rates=tax_rates)) @@ -1514,6 +1529,7 @@ def _get_next_invoice(cls, customer=None, subscription=None, limit=99) for previous_invoice in previous._list: old_plan = previous_invoice.lines._list[0].plan + old_price = previous_invoice.lines._list[0].price old_tax_rates = [ tr.id for tr in previous_invoice.lines._list[0].tax_rates] @@ -1523,7 +1539,8 @@ def _get_next_invoice(cls, customer=None, subscription=None, proration=True, description='Unused time', subscription=subscription, - plan=old_plan.id, + plan=old_plan.id if old_plan else None, + price=old_price.id if old_price else None, tax_rates=old_tax_rates, customer=customer, period_start=previous_invoice.period_start, @@ -1717,8 +1734,8 @@ class InvoiceItem(StripeObject): object = 'invoiceitem' _id_prefix = 'ii_' - def __init__(self, invoice=None, subscription=None, plan=None, amount=None, - currency=None, customer=None, period_start=None, + def __init__(self, invoice=None, subscription=None, plan=None, price=None, + amount=None, currency=None, customer=None, period_start=None, period_end=None, proration=False, description=None, tax_rates=[], metadata=None, **kwargs): if kwargs: @@ -1736,6 +1753,8 @@ def __init__(self, invoice=None, subscription=None, plan=None, amount=None, assert subscription.startswith('sub_') if plan is not None: assert type(plan) is str and plan + if price is not None: + assert type(price) is str and price assert type(amount) is int assert type(currency) is str and currency assert type(customer) is str and customer.startswith('cus_') @@ -1759,6 +1778,8 @@ def __init__(self, invoice=None, subscription=None, plan=None, amount=None, Invoice._api_retrieve(invoice) # to return 404 if not existant if plan is not None: plan = Plan._api_retrieve(plan) # to return 404 if not existant + if price is not None: + price = Price._api_retrieve(price) # to return 404 if not existant if len(tax_rates): # To return 404 if not existant: tax_rates = [TaxRate._api_retrieve(tr) for tr in tax_rates] @@ -1769,6 +1790,7 @@ def __init__(self, invoice=None, subscription=None, plan=None, amount=None, self.invoice = invoice self.subscription = subscription self.plan = plan + self.price = price self.quantity = 1 self.amount = amount self.currency = currency @@ -1820,15 +1842,20 @@ def __init__(self, item): self.subscription_item = item.id self.subscription = item._subscription self.plan = item.plan + self.price = item.price self.proration = False - self.currency = item.plan.currency - self.description = item.plan.name + self.currency = ( + item.plan.currency if item.plan else + item.price.currency + ) + self.description = item.plan.name if item.plan else None self.amount = item._calculate_amount() self.period = item._current_period() elif self.type == 'invoiceitem': self.invoice_item = item.id self.subscription = item.subscription self.plan = item.plan + self.price = item.price self.proration = item.proration self.currency = item.currency self.description = item.description @@ -2595,6 +2622,83 @@ def _api_delete(cls, id): extra_apis.append(('POST', '/v1/payouts/{id}/cancel', Payout._api_cancel)) +class Price(StripeObject): + object = 'price' + _id_prefix = 'price_' + + def __init__(self, id=None, active=None, currency=None, metadata=None, + nickname=None, product=None, product_data=None, + recurring=None, unit_amount=None, **kwargs): + if kwargs: + raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) + + assert id is None or type(id) is str + if active is None: + active = True + else: + active = try_convert_to_bool(active) + assert currency is None or type(currency) is str and currency + assert metadata is None or type(metadata) is dict + assert nickname is None or type(nickname) is str + assert product is None or type(product) is str and product + if unit_amount is not None: + unit_amount = try_convert_to_int(unit_amount) + assert type(unit_amount) is int and unit_amount >= 0 + if recurring is not None: + assert type(recurring) is dict + assert 'interval' in recurring + assert recurring['interval'] in ('day', 'week', 'month', 'year') + if 'interval_count' in recurring: + interval_count = \ + try_convert_to_int(recurring['interval_count']) + assert type(interval_count) is int and interval_count > 0 + # TODO: Add support for "meter" and "usage_type". + if product is not None: + Product._api_retrieve(product) # to return 404 if not existant + if product_data is not None: + assert isinstance(product_data, dict) + assert 'name' in product_data + product = Product(name=product_data['name']).id + + super().__init__(id) + + self.active = active + self.currency = currency + self.metadata = metadata + self.nickname = nickname + self.product = product + self.recurring = recurring or {} + self.unit_amount = unit_amount + + schedule_webhook(Event('price.created', self)) + + @classmethod + def _api_list_all(cls, url, active=None, product=None, limit=None, + starting_after=None, **kwargs): + if kwargs: + raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) + + active = try_convert_to_bool(active) + try: + if active is not None: + assert type(active) is bool + if product is not None: + assert type(product) is str + except AssertionError: + raise UserError(400, 'Bad request') + + li = super(Price, cls)._api_list_all( + url, limit=limit, starting_after=starting_after + ) + + if active is not None: + li._list = [obj for obj in li._list if obj.active == active] + if product is not None: + li._list = [obj for obj in li._list if obj.product == product] + + return li + + class Product(StripeObject): object = 'product' _id_prefix = 'prod_' @@ -2997,7 +3101,15 @@ def __init__(self, customer=None, metadata=None, items=None, assert proration_behavior in ['create_prorations', 'none'] assert type(items) is list for item in items: - assert type(item.get('plan')) is str + assert isinstance(item, dict) + if 'price' in item: + assert isinstance(item['price'], str) + assert 'plan' not in item + elif 'plan' in item: + assert isinstance(item['plan'], str) + assert 'price' not in item + else: + assert False if item.get('quantity') is not None: item['quantity'] = try_convert_to_int(item['quantity']) assert type(item['quantity']) is int @@ -3021,7 +3133,12 @@ def __init__(self, customer=None, metadata=None, items=None, Customer._api_retrieve(customer) # to return 404 if not existant for item in items: - Plan._api_retrieve(item['plan']) # to return 404 if not existant + if 'price' in item: + # to return 404 if not existant + Price._api_retrieve(item['price']) + elif 'plan' in item: + # to return 404 if not existant + Plan._api_retrieve(item['plan']) # To return 404 if not existant: if len(item['tax_rates']): [TaxRate._api_retrieve(tr) for tr in item['tax_rates']] @@ -3060,7 +3177,8 @@ def __init__(self, customer=None, metadata=None, items=None, self.items._list.append( SubscriptionItem( subscription=self.id, - plan=items[0]['plan'], + plan=items[0].get('plan'), + price=items[0].get('price'), quantity=items[0]['quantity'], metadata=items[0]['metadata'], tax_rates=items[0]['tax_rates'])) @@ -3076,6 +3194,10 @@ def __init__(self, customer=None, metadata=None, items=None, def plan(self): return self.items._list[0].plan + @property + def price(self): + return self.items._list[0].price + @property def current_period_start(self): return self.items._list[0]._current_period()['start'] @@ -3213,17 +3335,23 @@ def _update(self, metadata=None, items=None, trial_end=None, raise UserError(400, 'Bad request') old_plan = self.plan + old_price = self.price if items is not None: if len(items) != 1: raise UserError(500, 'Not implemented') # If no plan specified in update request, we stay on the current # one - if not items[0].get('plan'): - items[0]['plan'] = self.plan.id - - # To return 404 if not existant: - Plan._api_retrieve(items[0]['plan']) + if old_plan: + if not items[0].get('plan'): + items[0]['plan'] = self.plan.id + # To return 404 if not existant: + Plan._api_retrieve(items[0]['plan']) + elif old_price: + if not items[0].get('price'): + items[0]['price'] = self.price.id + # To return 404 if not existant: + Price._api_retrieve(items[0]['price']) # To return 404 if not existant: if len(items[0]['tax_rates']): @@ -3231,12 +3359,22 @@ def _update(self, metadata=None, items=None, trial_end=None, self.quantity = items[0]['quantity'] - if (self.items._list[0].plan.id != items[0]['plan'] or - self.items._list[0].quantity != items[0]['quantity']): + if ( + ( + self.items._list[0].plan and + self.items._list[0].plan.id != items[0].get('plan') + ) or + self.items._list[0].quantity != items[0]['quantity'] or + ( + self.items._list[0].price and + self.items._list[0].price.id != items[0].get('price') + ) + ): self.items = List('/v1/subscription_items?subscription=' + self.id) item = SubscriptionItem(subscription=self.id, - plan=items[0]['plan'], + plan=items[0].get('plan'), + price=items[0].get('price'), quantity=items[0]['quantity'], metadata=items[0]['metadata'], tax_rates=items[0]['tax_rates']) @@ -3257,7 +3395,8 @@ def _update(self, metadata=None, items=None, trial_end=None, proration=True, description='Unused time', subscription=self.id, - plan=old_plan.id, + plan=old_plan.id if old_plan else None, + price=old_price.id if old_price else None, tax_rates=previous_tax_rates, customer=self.customer) @@ -3265,7 +3404,8 @@ def _update(self, metadata=None, items=None, trial_end=None, self.items = List('/v1/subscription_items?subscription=' + self.id) item = SubscriptionItem(subscription=self.id, - plan=items[0]['plan'], + plan=items[0].get('plan'), + price=items[0].get('price'), quantity=items[0]['quantity'], tax_rates=items[0]['tax_rates']) self.items._list.append(item) @@ -3288,9 +3428,22 @@ def _update(self, metadata=None, items=None, trial_end=None, # If the subscription is updated to a more expensive plan, an invoice # is not automatically generated. To achieve that, an invoice has to # be manually created using the POST /invoices route. - create_an_invoice = self.plan.billing_scheme == 'per_unit' and ( - self.plan.interval != old_plan.interval or - self.plan.interval_count != old_plan.interval_count) + create_an_invoice = ( + self.plan and + self.plan.billing_scheme == 'per_unit' and + ( + self.plan.interval != old_plan.interval or + self.plan.interval_count != old_plan.interval_count + ) + ) or ( + self.price and + ( + self.price.recurring.get('interval') != + old_price.recurring.get('interval') or + self.price.recurring.get('interval_count') != + old_price.recurring.get('interval_count') + ) + ) if create_an_invoice: self._create_invoice() @@ -3333,7 +3486,7 @@ class SubscriptionItem(StripeObject): object = 'subscription_item' _id_prefix = 'si_' - def __init__(self, subscription=None, plan=None, quantity=1, + def __init__(self, subscription=None, plan=None, price=None, quantity=1, tax_rates=[], metadata=None, **kwargs): if kwargs: raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys())) @@ -3343,14 +3496,22 @@ def __init__(self, subscription=None, plan=None, quantity=1, if subscription is not None: assert type(subscription) is str assert subscription.startswith('sub_') - assert type(plan) is str + if plan is None: + assert isinstance(price, str) + elif price is None: + assert isinstance(plan, str) + else: + assert False assert type(quantity) is int and quantity > 0 assert type(tax_rates) is list assert all(type(tr) is str for tr in tax_rates) except AssertionError: raise UserError(400, 'Bad request') - plan = Plan._api_retrieve(plan) # to return 404 if not existant + if plan is not None: + plan = Plan._api_retrieve(plan) # to return 404 if not existant + if price is not None: + price = Price._api_retrieve(price) # to return 404 if not existant # To return 404 if not existant: if len(tax_rates): tax_rates = [TaxRate._api_retrieve(tr) for tr in tax_rates] @@ -3359,6 +3520,7 @@ def __init__(self, subscription=None, plan=None, quantity=1, super().__init__() self.plan = plan + self.price = price self.quantity = quantity self.tax_rates = tax_rates self.metadata = metadata or {} @@ -3373,22 +3535,32 @@ def _current_period(self): start_date = int(time.time()) end_date = datetime.fromtimestamp(start_date) - if self.plan.interval == 'day': - end_date += timedelta(days=1) - elif self.plan.interval == 'week': - end_date += timedelta(days=7) - elif self.plan.interval == 'month': - end_date += relativedelta(months=1) - elif self.plan.interval == 'year': - end_date += relativedelta(years=1) + if self.plan is None: + assert self.price is not None + end_date += { + 'day': timedelta(days=1), + 'week': timedelta(weeks=1), + 'month': relativedelta(months=1), + 'year': relativedelta(years=1) + }[self.price.recurring['interval']] + if self.price is None: + assert self.plan is not None + if self.plan.interval == 'day': + end_date += timedelta(days=1) + elif self.plan.interval == 'week': + end_date += timedelta(days=7) + elif self.plan.interval == 'month': + end_date += relativedelta(months=1) + elif self.plan.interval == 'year': + end_date += relativedelta(years=1) return dict(start=start_date, end=int(end_date.timestamp())) def _calculate_amount(self): - if self.plan.billing_scheme == 'per_unit': + if self.plan and self.plan.billing_scheme == 'per_unit': return self.plan.amount * self.quantity - if self.plan.tiers_mode == 'volume': + if self.plan and self.plan.tiers_mode == 'volume': index = next( (i for i, t in enumerate(self.plan.tiers) if t['up_to'] == 'inf' @@ -3396,7 +3568,7 @@ def _calculate_amount(self): return self._calculate_amount_in_tier( self.quantity, index) - if self.plan.tiers_mode == 'graduated': + if self.plan and self.plan.tiers_mode == 'graduated': quantity = self.quantity amount = 0 @@ -3421,6 +3593,7 @@ def _calculate_amount(self): return 0 def _calculate_amount_in_tier(self, quantity, index): + assert self.plan is not None t = self.plan.tiers[index] return int(t['unit_amount']) * quantity + int(t['flat_amount']) diff --git a/localstripe/server.py b/localstripe/server.py index 172a292b..9b8a37ca 100644 --- a/localstripe/server.py +++ b/localstripe/server.py @@ -25,8 +25,8 @@ from .resources import BalanceTransaction, Charge, Coupon, Customer, Event, \ Invoice, InvoiceItem, PaymentIntent, PaymentMethod, Payout, Plan, \ - Product, Refund, SetupIntent, Source, Subscription, SubscriptionItem, \ - TaxRate, Token, extra_apis, store + Price, Product, Refund, SetupIntent, Source, Subscription, \ + SubscriptionItem, TaxRate, Token, extra_apis, store from .errors import UserError from .webhooks import register_webhook @@ -274,9 +274,9 @@ async def f(request): for cls in (BalanceTransaction, Charge, Coupon, Customer, Event, Invoice, - InvoiceItem, PaymentIntent, PaymentMethod, Payout, Plan, Product, - Refund, SetupIntent, Source, Subscription, SubscriptionItem, - TaxRate, Token): + InvoiceItem, PaymentIntent, PaymentMethod, Payout, Plan, Price, + Product, Refund, SetupIntent, Source, Subscription, + SubscriptionItem, TaxRate, Token): for method, url, func in ( ('POST', '/v1/' + cls.object + 's', api_create), ('GET', '/v1/' + cls.object + 's/{id}', api_retrieve), diff --git a/test.sh b/test.sh index 5049a848..ced10f18 100755 --- a/test.sh +++ b/test.sh @@ -1295,3 +1295,25 @@ inv=$(curl -sSfg -u $SK: $HOST/v1/subscriptions \ total=$(curl -sSfg -u $SK: $HOST/v1/invoices/$inv \ | grep -oP '"total": \K([0-9]+)' ) [ "$total" -eq 16383 ] + +# Create a price under an existing product. +curl -sSfg -u $SK: $HOST/v1/prices \ + -d id=price_abc001 \ + -d currency=usd \ + -d unit_amount=1000 \ + -d recurring[interval]=month \ + -d product=PRODUCT1234 + +# Create a price and create a new product as a side-effect. +curl -sSfg -u $SK: $HOST/v1/prices \ + -d id=price_abc002 \ + -d currency=usd \ + -d unit_amount=10000 \ + -d recurring[interval]=year \ + -d product_data[name]=Gold\ Plan + +# List prices with filters +total_count=$( + curl -sSfg -u $SK: "$HOST/v1/prices?limit=100&active=true" \ + | grep -oE '"total_count": 2' +)