{% for product in product_list %}
{% endfor %}
diff --git a/src/storefront/urls.py b/src/storefront/urls.py
index 5f626f1..5443db6 100644
--- a/src/storefront/urls.py
+++ b/src/storefront/urls.py
@@ -5,11 +5,6 @@ urlpatterns = [
path('about/', views.AboutView.as_view(), name='about'),
path('fair-trade/', views.FairTradeView.as_view(), name='fair-trade'),
path('reviews/', views.ReviewListView.as_view(), name='reviews'),
- path(
- 'subscriptions/',
- views.SubscriptionCreateView.as_view(),
- name='subscriptions'
- ),
path(
'categories/
/',
@@ -104,4 +99,50 @@ urlpatterns = [
name='address-update',
)
])),
+
+ path(
+ 'stripe-webhook/',
+ views.stripe_webhook,
+ name='stripe-webhook'
+ ),
+ # Subscriptions
+ path('subscriptions/', include([
+ path(
+ 'form/',
+ views.SubscriptionFormView.as_view(),
+ name='subscription-form'
+ ),
+ path(
+ 'address/',
+ views.SubscriptionAddAddressView.as_view(),
+ name='subscription-address'
+ ),
+ path(
+ 'new/',
+ views.SubscriptionCreateView.as_view(),
+ name='subscription-create'
+ ),
+ path(
+ 'done/',
+ views.SubscriptionDoneView.as_view(),
+ name='subscription-done'
+ ),
+ path('/', include([
+ path(
+ '',
+ views.SubscriptionDetailView.as_view(),
+ name='subscription-detail'
+ ),
+ path(
+ 'update/',
+ views.SubscriptionUpdateView.as_view(),
+ name='subscription-update'
+ ),
+ path(
+ 'delete/',
+ views.SubscriptionDeleteView.as_view(),
+ name='subscription-delete'
+ ),
+ ])),
+ ])),
]
diff --git a/src/storefront/views.py b/src/storefront/views.py
index 7134324..070943d 100644
--- a/src/storefront/views.py
+++ b/src/storefront/views.py
@@ -1,13 +1,16 @@
import logging
+import locale
import requests
import json
import stripe
+from decimal import Decimal
from django.conf import settings
from django.utils import timezone
from django.shortcuts import render, reverse, redirect, get_object_or_404
from django.urls import reverse_lazy
from django.core.mail import EmailMessage
from django.core.cache import cache
+from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.http import JsonResponse, HttpResponseRedirect
from django.views.generic.base import View, RedirectView, TemplateView
@@ -26,7 +29,8 @@ from django.forms.models import model_to_dict
from django.db.models import (
Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value
)
-
+from measurement.measures import Weight
+from measurement.utils import guess
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersCaptureRequest
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment
@@ -38,20 +42,23 @@ from accounts.forms import (
from core.models import (
ProductCategory, Product, ProductVariant, ProductOption,
Order, Transaction, OrderLine, Coupon, ShippingRate,
- SiteSettings
+ Subscription, SiteSettings
)
from core.forms import ShippingRateForm
+from core.shipping import get_shipping_cost
from core import OrderStatus, ShippingContainer
from .forms import (
AddToCartForm, CartItemUpdateForm, OrderCreateForm,
- AddressForm, CouponApplyForm, ContactForm, CheckoutShippingForm,
- SubscriptionCreateForm
+ AddressForm, CouponApplyForm, CheckoutShippingForm,
+ SubscriptionForm
)
from .cart import CartItem, Cart
from .payments import CaptureOrder
logger = logging.getLogger(__name__)
+locale.setlocale(locale.LC_ALL, '')
+stripe.api_key = settings.STRIPE_API_KEY
class CartView(FormView):
@@ -365,7 +372,7 @@ class OrderCreateView(CreateView):
shipping_container = cart.get_shipping_container()
form.instance.shipping_total = cart.get_shipping_price(shipping_container)
shipping_address = self.request.session.get('shipping_address')
- form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address)
+ form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, shipping_address)
form.instance.status = OrderStatus.DRAFT
self.object = form.save()
bulk_list = cart.build_bulk_list(self.object)
@@ -379,57 +386,6 @@ class OrderCreateView(CreateView):
return JsonResponse(data)
-# stripe listen --forward-to localhost:8000/stripe-webhook
-@csrf_exempt
-@require_POST
-def stripe_webhook(request):
- # You can use webhooks to receive information about asynchronous payment events.
- # For more about our webhook events check out https://stripe.com/docs/webhooks.
- webhook_secret = os.getenv('STRIPE_WEBHOOK_SECRET')
- request_data = json.loads(request.data)
-
- if webhook_secret:
- # Retrieve the event by verifying the signature using the raw body and secret if webhook signing is configured.
- signature = request.headers.get('stripe-signature')
- try:
- event = stripe.Webhook.construct_event(
- payload=request.data, sig_header=signature, secret=webhook_secret)
- data = event['data']
- except Exception as e:
- return e
- # Get the type of webhook event sent - used to check the status of PaymentIntents.
- event_type = event['type']
- else:
- data = request_data['data']
- event_type = request_data['type']
-
- data_object = data['object']
-
- if event_type == 'invoice.paid':
- # Used to provision services after the trial has ended.
- # The status of the invoice will show up as paid. Store the status in your
- # database to reference when a user accesses your service to avoid hitting rate
- # limits.
- messages.success(request, 'Paid')
- logger.warning(data)
-
- if event_type == 'invoice.payment_failed':
- # If the payment fails or the customer does not have a valid payment method,
- # an invoice.payment_failed event is sent, the subscription becomes past_due.
- # Use this webhook to notify your user that their payment has
- # failed and to retrieve new card details.
- messages.warning(request, 'Payment failed')
- logger.warning(data)
-
- if event_type == 'customer.subscription.deleted':
- # handle subscription canceled automatically based
- # upon your subscription settings. Or if the user cancels it.
- messages.error(request, 'Deleted')
- logger.warning(data)
-
- return jsonify({'status': 'success'})
-
-
@csrf_exempt
@require_POST
def paypal_order_transaction_capture(request, transaction_id):
@@ -584,64 +540,338 @@ class ReviewListView(TemplateView):
template_name = 'storefront/reviews.html'
-class SubscriptionCreateView(FormView):
- template_name = 'storefront/subscriptions.html'
- form_class = SubscriptionCreateForm
- success_url = reverse_lazy('storefront:payment-done')
+class SubscriptionFormView(FormView):
+ template_name = 'storefront/subscription/form.html'
+ form_class = SubscriptionForm
+ success_url = reverse_lazy('storefront:subscription-address')
+
+ def get_stripe_products(self):
+ # id, name
+ product_list = [{
+ 'id': product['id'],
+ 'name': product['name'],
+ 'created': product['created'],
+ 'weight_per_item': product['metadata']['weight_per_item'],
+ 'cost': product['metadata']['cost'],
+ 'prices': []
+ } for product in stripe.Product.list(active=True)]
+
+ # id, product, recurring.interval_count, recurring.interval, unit_amount
+ price_list = [{
+ 'id': price['id'],
+ 'product': price['product'],
+ 'interval_count': price.recurring.interval_count,
+ 'interval': price.recurring.interval,
+ 'unit_amount': price['unit_amount']
+ } for price in stripe.Price.list(active=True)]
+
+ for prod in product_list:
+ prod['prices'] = list(filter(
+ lambda p: True if p['product'] == prod['id'] else False,
+ price_list
+ ))
+ return sorted(product_list, key=lambda p: p['created'])
def get_context_data(self, *args, **kwargs):
- stripe.api_key = settings.STRIPE_API_KEY
context = super().get_context_data(*args, **kwargs)
- context['STRIPE_API_KEY'] = settings.STRIPE_API_KEY
+ context['stripe_products'] = self.get_stripe_products()
context['product_list'] = Product.objects.filter(
- visible_in_listings=True
+ visible_in_listings=True,
+ category__name='Coffee'
)
- context['stripe_products'] = stripe.Price.list()
return context
def form_valid(self, form):
- # TODO: Construct items element
- items = []
- subscription = self.create_subscription(items)
+ self.request.session['subscription'] = {
+ 'items': [{
+ 'price': form.cleaned_data['stripe_price_id'],
+ 'quantity': form.cleaned_data['total_quantity']
+ }],
+ 'metadata': {
+ 'grind': form.cleaned_data['grind'],
+ 'total_weight': form.cleaned_data['total_weight'],
+ 'products_and_quantities': json.loads(form.cleaned_data['products_and_quantities'])
+ }
+ }
return super().form_valid(form)
- def create_subscription(self, items):
- # items=[{
- # 'price': price_id,
- # 'quantity': quantity
- # }, {
- # 'price': next_price_id,
- # 'quantity': quantity
- # }]
- try:
- # Create the subscription. Note we're expanding the Subscription's
- # latest invoice and that invoice's payment_intent
- # so we can pass it to the front end to confirm the payment
- subscription = stripe.Subscription.create(
- customer=self.request.user.stripe_id,
- items=items,
- payment_behavior='default_incomplete',
- payment_settings={'save_default_payment_method': 'on_subscription'},
- expand=['latest_invoice.payment_intent'],
- )
- # TODO: pass this secret to the sub_payment.html as 'CLIENT_SECRET'
- # clientSecret=subscription.latest_invoice.payment_intent.client_secret
- return subscription
- except Exception as e:
- return messages.error(self.request, e.user_message)
+class SubscriptionAddAddressView(FormView):
+ template_name = 'storefront/subscription/address.html'
+ form_class = AddressForm
+ success_url = reverse_lazy('storefront:subscription-create')
+ def get_initial(self):
+ user = self.request.user
+ initial = None
+ if user.is_authenticated and user.default_shipping_address:
+ address = user.default_shipping_address
+ initial = {
+ 'full_name': address.first_name + ' ' + address.last_name,
+ 'email': user.email,
+ 'street_address_1': address.street_address_1,
+ 'street_address_2': address.street_address_2,
+ 'city': address.city,
+ 'state': address.state,
+ 'postal_code': address.postal_code
+ }
+ elif self.request.session.get('shipping_address'):
+ address = self.request.session.get('shipping_address')
+ initial = {
+ 'full_name': address['first_name'] + ' ' + address['last_name'],
+ 'email': address['email'],
+ 'street_address_1': address['street_address_1'],
+ 'street_address_2': address['street_address_2'],
+ 'city': address['city'],
+ 'state': address['state'],
+ 'postal_code': address['postal_code']
+ }
+ return initial
-class CreatePayment(View):
- def post(self, request, *args, **kwargs):
- stripe.api_key = settings.STRIPE_API_KEY
- intent = stripe.PaymentIntent.create(
- amount=2000,
- currency=settings.DEFAULT_CURRENCY,
- automatic_payment_methods={
- 'enabled': True,
- },
+ def form_valid(self, form):
+ # save address data to session
+ cleaned_data = form.cleaned_data
+ first_name, last_name = form.process_full_name(
+ cleaned_data.get('full_name')
)
- return JsonResponse({
- 'clientSecret': intent['client_secret']
+ address = {
+ 'first_name': first_name,
+ 'last_name': last_name,
+ 'email': cleaned_data['email'],
+ 'street_address_1': cleaned_data['street_address_1'],
+ 'street_address_2': cleaned_data['street_address_2'],
+ 'city': cleaned_data['city'],
+ 'state': cleaned_data['state'],
+ 'postal_code': cleaned_data['postal_code']
+ }
+ self.request.session['shipping_address'] = address
+ return super().form_valid(form)
+
+
+class SubscriptionCreateView(SuccessMessageMixin, CreateView):
+ model = Subscription
+ success_message = 'Subscription created.'
+ template_name = 'storefront/subscription/create_form.html'
+ fields = []
+
+ def get_item_list(self):
+ item_list = [{
+ 'product': Product.objects.get(pk=item['pk']),
+ 'quantity': item['quantity']
+ } for item in self.request.session['subscription']['metadata']['products_and_quantities']]
+ return item_list
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ subscription = self.request.session['subscription']
+ metadata = subscription['metadata']
+ price = stripe.Price.retrieve(
+ subscription['items'][0].get('price'),
+ expand=['product']
+ )
+ shipping_address = self.request.session.get('shipping_address')
+ weight, unit = metadata['total_weight'].split(':')
+ total_weight = guess(float(weight), unit, measures=[Weight])
+ subtotal_price = (Decimal(price.unit_amount) * subscription['items'][0]['quantity']) / 100
+ shipping_cost = get_shipping_cost(
+ total_weight,
+ shipping_address['postal_code']
+ )
+ total_price = subtotal_price + shipping_cost
+ context['sub_cart'] = {
+ 'items': self.get_item_list(),
+ 'size': price.product.name,
+ 'grind': metadata['grind'],
+ 'schedule': f'Every {price.recurring.interval_count} / {price.recurring.interval}',
+ 'subtotal_price': locale.currency(subtotal_price),
+ 'shipping_cost': shipping_cost,
+ 'total_price': total_price,
+ 'total_weight': guess(float(weight), unit, measures=[Weight])
+ }
+ context['shipping_address'] = shipping_address
+ return context
+
+ def get_line_items(self):
+ line_items = self.object.items
+ recurring = stripe.Price.retrieve(
+ line_items[0].get('price')
+ ).get('recurring')
+ shipping_cost = get_shipping_cost(
+ self.object.total_weight,
+ self.object.shipping_address.postal_code
+ ) * 100
+ line_items.append({
+ 'price_data': {
+ 'currency': settings.DEFAULT_CURRENCY.lower(),
+ 'unit_amount': int(shipping_cost),
+ 'product_data': {
+ 'name': 'Shipping'
+ },
+ 'recurring': {
+ 'interval': recurring.interval,
+ 'interval_count': recurring.interval_count
+ }
+ },
+ 'quantity': 1
})
+ return line_items
+
+ def get_success_url(self):
+ session = stripe.checkout.Session.create(
+ customer=self.object.customer.get_or_create_stripe_id(),
+ success_url='http://' + Site.objects.get_current().domain + reverse(
+ 'storefront:subscription-detail', kwargs={'pk': self.object.pk}
+ ) + '?session_id={CHECKOUT_SESSION_ID}',
+ cancel_url='http://' + Site.objects.get_current().domain + reverse(
+ 'storefront:subscription-create'),
+ mode='subscription',
+ line_items=self.get_line_items(),
+ subscription_data={'metadata': self.object.format_metadata()}
+ )
+ return session.url
+
+ def form_valid(self, form):
+ shipping_address = self.request.session.get('shipping_address')
+ subscription = self.request.session.get('subscription')
+ form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, shipping_address)
+ weight, unit = subscription['metadata']['total_weight'].split(':')
+ form.instance.total_weight = guess(
+ float(weight), unit, measures=[Weight]
+ )
+ form.instance.items = subscription['items']
+ form.instance.metadata = subscription['metadata']
+ return super().form_valid(form)
+
+
+class SubscriptionDetailView(DetailView):
+ model = Subscription
+ template_name = 'storefront/subscription/detail.html'
+
+
+class SubscriptionDoneView(TemplateView):
+ template_name = 'storefront/subscription/done.html'
+
+
+class SubscriptionUpdateView(SuccessMessageMixin, UpdateView):
+ model = Subscription
+ success_message = 'Subscription saved.'
+ fields = '__all__'
+
+
+class SubscriptionDeleteView(SuccessMessageMixin, DeleteView):
+ model = Subscription
+ success_message = 'Subscription deleted.'
+ success_url = reverse_lazy('subscription-list')
+
+
+# stripe listen --forward-to localhost:8000/stripe-webhook
+@csrf_exempt
+@require_POST
+def stripe_webhook(request):
+ # You can use webhooks to receive information about asynchronous payment events.
+ # For more about our webhook events check out https://stripe.com/docs/webhooks.
+ webhook_secret = None
+ request_data = json.loads(request.body)
+
+ if webhook_secret:
+ # Retrieve the event by verifying the signature using the raw body
+ # and secret if webhook signing is configured.
+ signature = request.headers.get('stripe-signature')
+ try:
+ event = stripe.Webhook.construct_event(
+ payload=request.data, sig_header=signature, secret=webhook_secret)
+ data = event['data']
+ except Exception as e:
+ return e
+ # Get the type of webhook event sent - used to check the status
+ # of PaymentIntents.
+ event_type = event['type']
+ else:
+ data = request_data['data']
+ event_type = request_data['type']
+
+ data_object = data['object']
+
+ logger.warning('\n')
+ logger.warning(event_type.upper() + ':\n')
+ logger.warning(data)
+ logger.warning('\n')
+
+ if event_type == 'checkout.session.completed':
+ # Payment is successful and the subscription is created.
+ # You should provision the subscription and save the customer ID to your database.
+ pass
+
+ if event_type == 'customer.subscription.created':
+ try:
+ subscription = Subscription.objects.get(
+ pk=data_object['metadata'].get('subscription_pk')
+ )
+ except Subscription.DoesNotExist:
+ logger.warning('Subscription does not exist')
+ raise
+ else:
+ subscription.stripe_id = data_object['id']
+ subscription.is_active = True
+ subscription.save()
+
+ if event_type == 'invoice.paid':
+ # Continue to provision the subscription as payments continue to be made.
+ # Store the status in your database and check when a user accesses your service.
+ # This approach helps you avoid hitting rate limits.
+ try:
+ subscription = Subscription.objects.get(
+ stripe_id=data_object['subscription']
+ )
+ except Subscription.DoesNotExist:
+ logger.warning('Subscription does not exist')
+ raise
+ else:
+ subscription.create_order(data_object)
+
+ if event_type == 'invoice.payment_failed':
+ # The payment failed or the customer does not have a valid payment method.
+ # The subscription becomes past_due. Notify your customer and send them to the
+ # customer portal to update their payment information.
+ pass
+
+ if event_type == 'invoice.created':
+ # Add shipping cost as an item on the invoice
+ # shipping_cost = get_shipping_cost(
+ # self.object.total_weight,
+ # self.request.user.default_shipping_address.postal_code
+ # ) * 100
+ # stripe.InvoiceItem.create(
+ # customer=data_object['customer'],
+ # subscription=data_object['subscription'],
+ # description='Shipping',
+ # unit_amount=1234,
+ # currency=settings.DEFAULT_CURRENCY.lower()
+ # )
+ pass
+
+ # # if event_type == 'checkout.session.completed':
+
+ # if event_type == 'invoice.paid':
+ # # Used to provision services after the trial has ended.
+ # # The status of the invoice will show up as paid. Store the status in your
+ # # database to reference when a user accesses your service to avoid hitting rate
+ # # limits.
+ # messages.success(request, 'Paid')
+ # logger.warning(data)
+
+ # if event_type == 'invoice.payment_failed':
+ # # If the payment fails or the customer does not have a valid payment method,
+ # # an invoice.payment_failed event is sent, the subscription becomes past_due.
+ # # Use this webhook to notify your user that their payment has
+ # # failed and to retrieve new card details.
+ # messages.warning(request, 'Payment failed')
+ # logger.warning(data)
+
+ # if event_type == 'customer.subscription.deleted':
+ # # handle subscription canceled automatically based
+ # # upon your subscription settings. Or if the user cancels it.
+ # messages.error(request, 'Deleted')
+ # logger.warning(data)
+
+ return JsonResponse({'status': 'success'})
diff --git a/src/templates/base.html b/src/templates/base.html
index 7de20d5..4b9a646 100644
--- a/src/templates/base.html
+++ b/src/templates/base.html
@@ -49,7 +49,7 @@
{% for category in category_list %}
{{ category }}
{% endfor %}
-
+ Subscriptions
Fair trade
Reviews
About
diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html
index 0f6734a..5c1a0b8 100644
--- a/src/templates/dashboard.html
+++ b/src/templates/dashboard.html
@@ -41,6 +41,10 @@
Orders
+
+
+ Subscriptions
+
Customers
diff --git a/subscription_object.py b/subscription_object.py
new file mode 100644
index 0000000..abf1fef
--- /dev/null
+++ b/subscription_object.py
@@ -0,0 +1,225 @@
+data_object: {
+ "id": "in_1MGQM8FQsgcNaCv6JUfpAHbM",
+ "object": "invoice",
+ "account_country": "US",
+ "account_name": "nathanchapman",
+ "account_tax_ids": None,
+ "amount_due": 5355,
+ "amount_paid": 5355,
+ "amount_remaining": 0,
+ "application": None,
+ "application_fee_amount": None,
+ "attempt_count": 1,
+ "attempted": True,
+ "auto_advance": False,
+ "automatic_tax": {"enabled": False, "status": None},
+ "billing_reason": "subscription_create",
+ "charge": "ch_3MGQM9FQsgcNaCv613M8vu43",
+ "collection_method": "charge_automatically",
+ "created": 1671383336,
+ "currency": "usd",
+ "custom_fields": None,
+ "customer": "cus_N0BNLHLuTQJRu4",
+ "customer_address": None,
+ "customer_email": "contact@nathanjchapman.com",
+ "customer_name": "Nathan JChapman",
+ "customer_phone": None,
+ "customer_shipping": {
+ "address": {
+ "city": "Logan",
+ "country": None,
+ "line1": "1579 Talon Dr",
+ "line2": "",
+ "postal_code": "84321",
+ "state": "UT",
+ },
+ "name": "Nathan Chapman",
+ "phone": None,
+ },
+ "customer_tax_exempt": "none",
+ "customer_tax_ids": [],
+ "default_payment_method": None,
+ "default_source": None,
+ "default_tax_rates": [],
+ "description": None,
+ "discount": None,
+ "discounts": [],
+ "due_date": None,
+ "ending_balance": 0,
+ "footer": None,
+ "from_invoice": None,
+ "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1GLBrZFQsgcNaCv6/test_YWNjdF8xR0xCclpGUXNnY05hQ3Y2LF9OMFJFcDhJc3RyOXROMG1DeUNIRFVUVkdGMVNrRTRGLDYxOTI0MTM50200B7VnLQmS?s=ap",
+ "invoice_pdf": "https://pay.stripe.com/invoice/acct_1GLBrZFQsgcNaCv6/test_YWNjdF8xR0xCclpGUXNnY05hQ3Y2LF9OMFJFcDhJc3RyOXROMG1DeUNIRFVUVkdGMVNrRTRGLDYxOTI0MTM50200B7VnLQmS/pdf?s=ap",
+ "last_finalization_error": None,
+ "latest_revision": None,
+ "lines": {
+ "object": "list",
+ "data": [
+ {
+ "id": "il_1MGQM5FQsgcNaCv6baOBoXDl",
+ "object": "line_item",
+ "amount": 1035,
+ "amount_excluding_tax": 1035,
+ "currency": "usd",
+ "description": "Shipping",
+ "discount_amounts": [],
+ "discountable": True,
+ "discounts": [],
+ "invoice_item": "ii_1MGQM5FQsgcNaCv6UjIu6jMT",
+ "livemode": False,
+ "metadata": {},
+ "period": {"end": 1671383333, "start": 1671383333},
+ "plan": None,
+ "price": {
+ "id": "price_1MGB0tFQsgcNaCv62dtt2BHB",
+ "object": "price",
+ "active": False,
+ "billing_scheme": "per_unit",
+ "created": 1671324359,
+ "currency": "usd",
+ "custom_unit_amount": None,
+ "livemode": False,
+ "lookup_key": None,
+ "metadata": {},
+ "nickname": None,
+ "product": "prod_N0BN5Idzj7DdEj",
+ "recurring": None,
+ "tax_behavior": "unspecified",
+ "tiers_mode": None,
+ "transform_quantity": None,
+ "type": "one_time",
+ "unit_amount": 1035,
+ "unit_amount_decimal": "1035",
+ },
+ "proration": False,
+ "proration_details": {"credited_items": None},
+ "quantity": 1,
+ "subscription": None,
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "invoiceitem",
+ "unit_amount_excluding_tax": "1035",
+ },
+ {
+ "id": "il_1MGQM8FQsgcNaCv65ey9uwKi",
+ "object": "line_item",
+ "amount": 4320,
+ "amount_excluding_tax": 4320,
+ "currency": "usd",
+ "description": "3 × 16 oz Coffee (at $14.40 / month)",
+ "discount_amounts": [],
+ "discountable": True,
+ "discounts": [],
+ "livemode": False,
+ "metadata": {
+ "grind": '"Espresso"',
+ "total_weight": '"48:oz"',
+ "products_and_quantities": '[{"product": "Pantomime", "quantity": 2}, {"product": "Decaf", "quantity": 1}]',
+ },
+ "period": {"end": 1674061736, "start": 1671383336},
+ "plan": {
+ "id": "price_1MG7aEFQsgcNaCv6DZZoF2xG",
+ "object": "plan",
+ "active": True,
+ "aggregate_usage": None,
+ "amount": 1440,
+ "amount_decimal": "1440",
+ "billing_scheme": "per_unit",
+ "created": 1671311174,
+ "currency": "usd",
+ "interval": "month",
+ "interval_count": 1,
+ "livemode": False,
+ "metadata": {},
+ "nickname": None,
+ "product": "prod_N07pP13dnWszHN",
+ "tiers": None,
+ "tiers_mode": None,
+ "transform_usage": None,
+ "trial_period_days": None,
+ "usage_type": "licensed",
+ },
+ "price": {
+ "id": "price_1MG7aEFQsgcNaCv6DZZoF2xG",
+ "object": "price",
+ "active": True,
+ "billing_scheme": "per_unit",
+ "created": 1671311174,
+ "currency": "usd",
+ "custom_unit_amount": None,
+ "livemode": False,
+ "lookup_key": None,
+ "metadata": {},
+ "nickname": None,
+ "product": "prod_N07pP13dnWszHN",
+ "recurring": {
+ "aggregate_usage": None,
+ "interval": "month",
+ "interval_count": 1,
+ "trial_period_days": None,
+ "usage_type": "licensed",
+ },
+ "tax_behavior": "exclusive",
+ "tiers_mode": None,
+ "transform_quantity": None,
+ "type": "recurring",
+ "unit_amount": 1440,
+ "unit_amount_decimal": "1440",
+ },
+ "proration": False,
+ "proration_details": {"credited_items": None},
+ "quantity": 3,
+ "subscription": "sub_1MGQM8FQsgcNaCv61HhjRVJu",
+ "subscription_item": "si_N0REI0MTk1C3D2",
+ "tax_amounts": [],
+ "tax_rates": [],
+ "type": "subscription",
+ "unit_amount_excluding_tax": "1440",
+ },
+ ],
+ "has_more": False,
+ "total_count": 2,
+ "url": "/v1/invoices/in_1MGQM8FQsgcNaCv6JUfpAHbM/lines",
+ },
+ "livemode": False,
+ "metadata": {},
+ "next_payment_attempt": None,
+ "number": "86494117-0006",
+ "on_behalf_of": None,
+ "paid": True,
+ "paid_out_of_band": False,
+ "payment_intent": "pi_3MGQM9FQsgcNaCv61W7mCS0C",
+ "payment_settings": {
+ "default_mandate": None,
+ "payment_method_options": None,
+ "payment_method_types": None,
+ },
+ "period_end": 1671383336,
+ "period_start": 1671383336,
+ "post_payment_credit_notes_amount": 0,
+ "pre_payment_credit_notes_amount": 0,
+ "quote": None,
+ "receipt_number": None,
+ "rendering_options": None,
+ "starting_balance": 0,
+ "statement_descriptor": None,
+ "status": "paid",
+ "status_transitions": {
+ "finalized_at": 1671383336,
+ "marked_uncollectible_at": None,
+ "paid_at": 1671383338,
+ "voided_at": None,
+ },
+ "subscription": "sub_1MGQM8FQsgcNaCv61HhjRVJu",
+ "subtotal": 5355,
+ "subtotal_excluding_tax": 5355,
+ "tax": None,
+ "tax_percent": None,
+ "test_clock": None,
+ "total": 5355,
+ "total_discount_amounts": [],
+ "total_excluding_tax": 5355,
+ "total_tax_amounts": [],
+ "transfer_data": None,
+ "webhooks_delivered_at": None,
+}