Add full subscription checkout functionality

This commit is contained in:
Nathan Chapman 2022-12-30 07:23:23 -07:00
parent cb631823b2
commit 467e736147
48 changed files with 2360 additions and 1076 deletions

894
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'
# def ready(self):
# from .signals import (
# user_saved
# )
def ready(self):
from .signals import (
user_saved
)

View File

@ -1,3 +1,4 @@
import stripe
from django.db import models
from django.urls import reverse
from django.contrib.auth.models import AbstractUser
@ -17,6 +18,18 @@ class Address(models.Model):
)
postal_code = models.CharField(max_length=20, blank=True)
def as_stripe_dict(self):
return {
'name': f'{self.first_name} {self.last_name}',
'address': {
'line1': self.street_address_1,
'line2': self.street_address_2,
'city': self.city,
'state': self.state,
'postal_code': self.postal_code
}
}
def __str__(self):
return f"""
{self.first_name} {self.last_name}
@ -45,3 +58,13 @@ class User(AbstractUser):
Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL
)
stripe_id = models.CharField(max_length=255, blank=True)
def get_or_create_stripe_id(self):
if not self.stripe_id:
response = stripe.Customer.create(
name=self.first_name + ' ' + self.last_name,
email=self.email
)
self.stripe_id = response['id']
self.save()
return self.stripe_id

View File

@ -8,15 +8,11 @@ from django.conf import settings
from .models import Address, User
logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
@receiver(post_save, sender=User, dispatch_uid='user_saved')
def user_saved(sender, instance, created, **kwargs):
logger.info('User was saved')
if created or not instance.stripe_id:
stripe.api_key = settings.STRIPE_API_KEY
response = stripe.Customer.create(
name=instance.first_name + instance.last_name
)
instance.stripe_id = response['id']
instance.save()
instance.get_or_create_stripe_id()

View File

@ -4,7 +4,7 @@ from .models import Address, User
from .tasks import send_account_created_email
def get_or_create_customer(request, form, shipping_address):
def get_or_create_customer(request, shipping_address):
address, a_created = Address.objects.get_or_create(
first_name=shipping_address['first_name'],
last_name=shipping_address['last_name'],

View File

@ -190,21 +190,3 @@ class CoffeeGrind:
(PERCOLATOR, 'Percolator'),
(CAFE_STYLE, 'BLTC cafe pour over')
]
def build_usps_rate_request(weight, container, zip_destination):
service = ShippingContainer.get_shipping_service_from_container(container)
return \
{
'service': service,
'zip_origination': settings.DEFAULT_ZIP_ORIGINATION,
'zip_destination': zip_destination,
'pounds': weight,
'ounces': '0',
'container': container,
'width': '',
'length': '',
'height': '',
'girth': '',
'machinable': 'TRUE'
}

View File

@ -11,6 +11,7 @@ from .models import (
ShippingRate,
Order,
Transaction,
Subscription,
OrderLine,
)
@ -24,4 +25,5 @@ admin.site.register(Coupon)
admin.site.register(ShippingRate)
admin.site.register(Order)
admin.site.register(Transaction)
admin.site.register(Subscription)
admin.site.register(OrderLine)

View File

@ -7,7 +7,6 @@ class CoreConfig(AppConfig):
def ready(self):
from .signals import (
# variant_saved,
order_created,
transaction_created,
order_line_post_save,

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.2 on 2022-11-28 23:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_alter_order_coupon_amount'),
]
operations = [
migrations.AlterField(
model_name='productvariant',
name='stripe_id',
field=models.CharField(blank=True, db_index=True, max_length=255),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.0.2 on 2022-12-02 18:38
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0016_alter_productvariant_stripe_id'),
]
operations = [
migrations.CreateModel(
name='StripePrice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(blank=True, db_index=True, max_length=255)),
('unit_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
('interval', models.CharField(choices=[('day', 'Days'), ('week', 'Weeks'), ('month', 'Month'), ('year', 'Year')], default='month', max_length=16)),
('interval_count', models.PositiveIntegerField(default=1)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('variant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stripe_prices', to='core.productvariant')),
],
),
]

View File

@ -0,0 +1,58 @@
# Generated by Django 4.0.2 on 2022-12-04 00:55
from django.db import migrations, models
import django.db.models.deletion
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
('core', '0017_stripeprice'),
]
operations = [
migrations.CreateModel(
name='SubscriptionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(max_length=255)),
('quantity', models.PositiveIntegerField()),
('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)),
],
),
migrations.AddField(
model_name='subscription',
name='is_active',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='subscription',
name='options',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='subscription',
name='schedule',
field=models.CharField(choices=[('1 week', 'Every week'), ('2 weeks', 'Every 2 weeks'), ('1 month', 'Every month')], default='1 month', max_length=255),
),
migrations.AddField(
model_name='subscription',
name='size',
field=models.CharField(choices=[(12, '12 oz ($10.80)'), (16, '16 oz ($14.40)'), (75, '5 lbs ($67.50)')], default=16, max_length=255),
),
migrations.AlterField(
model_name='subscription',
name='stripe_id',
field=models.CharField(blank=True, db_index=True, max_length=255),
),
migrations.DeleteModel(
name='StripePrice',
),
migrations.AddField(
model_name='subscriptionitem',
name='subscription',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.subscription'),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.0.2 on 2022-12-04 01:05
from django.db import migrations, models
import django.db.models.deletion
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
('core', '0018_subscriptionitem_subscription_is_active_and_more'),
]
operations = [
migrations.CreateModel(
name='SubscriptionProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(blank=True, db_index=True, max_length=255)),
('product_name', models.CharField(max_length=255)),
('quantity', models.PositiveIntegerField()),
('weight', django_measurement.models.MeasurementField(blank=True, measurement=measurement.measures.mass.Mass, null=True)),
],
),
migrations.AlterField(
model_name='subscription',
name='size',
field=models.CharField(choices=[('12 oz', '12 oz ($10.80)'), ('16 oz', '16 oz ($14.40)'), ('5 lb', '5 lbs ($67.50)')], default='16 oz', max_length=255),
),
migrations.DeleteModel(
name='SubscriptionItem',
),
migrations.AddField(
model_name='subscriptionproduct',
name='subscription',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.subscription'),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 4.0.2 on 2022-12-04 01:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0019_subscriptionproduct_alter_subscription_size_and_more'),
]
operations = [
migrations.CreateModel(
name='SubscriptionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_id', models.CharField(blank=True, db_index=True, max_length=255)),
('product_name', models.CharField(max_length=255)),
('quantity', models.PositiveIntegerField()),
],
),
migrations.AlterField(
model_name='subscription',
name='schedule',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='subscription',
name='size',
field=models.CharField(max_length=255),
),
migrations.DeleteModel(
name='SubscriptionProduct',
),
migrations.AddField(
model_name='subscriptionitem',
name='subscription',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.subscription'),
),
]

View File

@ -0,0 +1,69 @@
# Generated by Django 4.0.2 on 2022-12-04 17:53
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0020_subscriptionitem_alter_subscription_schedule_and_more'),
]
operations = [
migrations.RemoveField(
model_name='subscription',
name='options',
),
migrations.RemoveField(
model_name='subscription',
name='schedule',
),
migrations.RemoveField(
model_name='subscription',
name='size',
),
migrations.AddField(
model_name='subscription',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='subscription',
name='items',
field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(blank=True, null=True), default=list, size=None),
),
migrations.AddField(
model_name='subscription',
name='metadata',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='subscription',
name='stripe_price_id',
field=models.CharField(blank=True, db_index=True, max_length=255),
),
migrations.AddField(
model_name='subscription',
name='total_quantity',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='subscription',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='subscription',
name='customer',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='subscriptions', to=settings.AUTH_USER_MODEL),
),
migrations.DeleteModel(
name='SubscriptionItem',
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.0.2 on 2022-12-04 18:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0021_remove_subscription_options_and_more'),
]
operations = [
migrations.RemoveField(
model_name='subscription',
name='stripe_price_id',
),
migrations.RemoveField(
model_name='subscription',
name='total_quantity',
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.0.2 on 2022-12-11 18:08
import core.weight
from django.db import migrations
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
('core', '0022_remove_subscription_stripe_price_id_and_more'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='total_weight',
field=django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.2 on 2022-12-16 22:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_user_stripe_id'),
('core', '0023_subscription_total_weight'),
]
operations = [
migrations.AddField(
model_name='subscription',
name='shipping_address',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounts.address'),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.0.2 on 2022-12-18 21:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0024_subscription_shipping_address'),
]
operations = [
migrations.AddIndex(
model_name='productvariant',
index=models.Index(fields=['stripe_id'], name='core_produc_stripe__6a1141_idx'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['stripe_id'], name='core_subscr_stripe__08018b_idx'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-12-18 21:36
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0025_productvariant_core_produc_stripe__6a1141_idx_and_more'),
]
operations = [
migrations.AddField(
model_name='orderline',
name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='core.product'),
),
]

View File

@ -1,4 +1,5 @@
import logging
import json
from decimal import Decimal
from PIL import Image
from measurement.measures import Weight
@ -24,9 +25,9 @@ from . import (
TransactionStatus,
OrderStatus,
ShippingProvider,
ShippingContainer,
build_usps_rate_request
ShippingContainer
)
from .usps import build_usps_rate_request
from .weight import WeightUnits, zero_weight
logger = logging.getLogger(__name__)
@ -197,45 +198,10 @@ class ProductVariant(models.Model):
class Meta:
ordering = ['sorting', 'weight']
class StripePrice:
# When this model is updated, it will query stripe and update all of the instances of this model
DAY = 'day'
WEEK = 'week'
MONTH = 'month'
YEAR = 'year'
INTERVAL_CHOICES = [
(DAY, 'Days'),
(WEEK, 'Weeks'),
(MONTH, 'Month'),
(YEAR, 'Year'),
indexes = [
models.Index(fields=['stripe_id'])
]
stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
variant = models.ForeignKey(
ProductVariant,
related_name='stripe_prices',
on_delete=models.CASCADE,
blank=True,
null=True,
)
unit_amount = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
blank=True,
null=True,
)
interval = models.CharField(
max_length=16,
choices=INTERVAL_CHOICES,
default=MONTH
)
interval_count = models.PositiveIntegerField(default=1)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True)
class ProductOption(models.Model):
"""
@ -476,6 +442,13 @@ class OrderLine(models.Model):
editable=False,
on_delete=models.CASCADE
)
product = models.ForeignKey(
Product,
related_name='order_lines',
on_delete=models.SET_NULL,
blank=True,
null=True,
)
variant = models.ForeignKey(
ProductVariant,
related_name='order_lines',
@ -538,14 +511,60 @@ class TrackingNumber(models.Model):
return self.tracking_id
class SubscriptionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class Subscription(models.Model):
stripe_id = models.CharField(max_length=255, blank=True)
customer = models.OneToOneField(
stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
customer = models.ForeignKey(
User,
related_name='subscription',
related_name='subscriptions',
on_delete=models.SET_NULL,
null=True
)
shipping_address = models.ForeignKey(
Address,
related_name='+',
editable=False,
null=True,
on_delete=models.SET_NULL
)
items = ArrayField(
models.JSONField(blank=True, null=True),
default=list
)
metadata = models.JSONField(blank=True, null=True)
is_active = models.BooleanField(default=False)
total_weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True
)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True)
def create_order(self, data_object):
pass
def format_metadata(self):
metadata = {}
for key, value in self.metadata.items():
metadata[key] = json.dumps(value)
metadata['subscription_pk'] = self.pk
return metadata
def get_absolute_url(self):
return reverse('storefront:subscription-detail', kwargs={'pk': self.pk})
class Meta:
indexes = [
models.Index(fields=['stripe_id'])
]
class SiteSettings(SingletonBase):

84
src/core/shipping.py Normal file
View File

@ -0,0 +1,84 @@
import logging
from decimal import Decimal
from django.conf import settings
from django.db.models import Q
from measurement.measures import Weight
from core.usps import USPSApi
from core.exceptions import USPSPostageError, ShippingAddressError
from core.models import (
ShippingRate,
SiteSettings,
)
from core import (
ShippingService,
ShippingProvider,
ShippingContainer
)
from core.usps import build_usps_rate_request
logger = logging.getLogger(__name__)
def get_shipping_container_choices_from_weight(weight):
is_selectable = Q(
is_selectable=True
)
min_weight_matched = Q(
min_order_weight__lte=weight) | Q(
min_order_weight__isnull=True
)
max_weight_matched = Q(
max_order_weight__gte=weight) | Q(
max_order_weight__isnull=True
)
containers = ShippingRate.objects.filter(
is_selectable & min_weight_matched & max_weight_matched
)
return containers
def get_shipping_container_from_choices(choices):
if len(choices) == 0:
return SiteSettings.load().default_shipping_rate.container
return choices[0].container
def get_shipping_cost(total_weight, postal_code):
if not total_weight > Weight(lb=0):
return Decimal('0.00')
container = get_shipping_container_from_choices(
get_shipping_container_choices_from_weight(total_weight)
)
usps_rate_request = build_usps_rate_request(
str(total_weight.lb), container, str(postal_code)
)
usps = USPSApi(settings.USPS_USER_ID, test=settings.DEBUG)
try:
validation = usps.get_rate(usps_rate_request)
except ConnectionError as e:
raise e(
'Could not connect to USPS, try again.'
)
logger.info(validation.result)
try:
postage = dict(
validation.result['RateV4Response']['Package']['Postage']
)
except KeyError:
raise USPSPostageError(
'Could not retrieve postage.'
)
if usps_rate_request['service'] == ShippingContainer.PRIORITY:
shipping_cost = Decimal(postage['Rate'])
elif usps_rate_request['service'] == ShippingContainer.PRIORITY_COMMERCIAL:
shipping_cost = Decimal(postage['CommercialRate'])
return shipping_cost

View File

@ -17,35 +17,7 @@ from .tasks import (
)
logger = logging.getLogger(__name__)
# @receiver(post_save, sender=ProductVariant, dispatch_uid='variant_created')
# def variant_saved(sender, instance, created, **kwargs):
# logger.info('Product was saved')
# if created or not instance.stripe_id:
# stripe.api_key = settings.STRIPE_API_KEY
# prod_response = stripe.Product.create(
# name=instance.product.name + ': ' + instance.name,
# description=instance.product.description
# )
# price_response = stripe.Price.create(
# unit_amount=int(instance.price * 100),
# currency=settings.DEFAULT_CURRENCY,
# product=prod_response['id']
# )
# instance.stripe_id = prod_response['id']
# instance.stripe_price_id = price_response['id']
# instance.save()
# else:
# stripe.Product.modify(
# instance.stripe_id,
# name=instance.product.name + ': ' + instance.name,
# description=instance.product.description
# )
# stripe.Price.modify(
# instance.stripe_price_id,
# unit_amount=int(instance.price * 100)
# )
stripe.api_key = settings.STRIPE_API_KEY
@receiver(post_save, sender=Order, dispatch_uid="order_created")
@ -102,7 +74,9 @@ def order_line_post_save(sender, instance, created, **kwargs):
pk=instance.order.pk
)[0]
order.status = get_order_status(order.total_quantity_fulfilled, order.total_quantity_ordered)
order.status = get_order_status(
order.total_quantity_fulfilled, order.total_quantity_ordered
)
order.save()
# order.update(

View File

@ -0,0 +1,84 @@
import locale
locale.setlocale(locale.LC_ALL, '')
data_object['subscription']
def convert_int_to_currency(price):
return locale.currency(int(price) / 100)
def convert_int_to_decimal(price):
return Decimal(str(price)[:-2] + '.' + str(price)[-2:])
def find_shipping_cost(data):
for x in data:
if x['description'] == 'Shipping':
return convert_int_to_currency(x['amount'])
break
else:
continue
def format_product(data, unit_price):
return {
'product': Product.objects.get(pk=data['pk']),
'quantity': data['quantity']
}
def find_products(data):
for x in data:
if 'products_and_quantities' in x['metadata']:
return map(format_product, x['metadata']['products_and_quantities'])
break
else:
continue
shipping_cost = None
unit_price = None
items = None
customer_note = ''
def deserialize_subscription(data):
for x in data:
if 'products_and_quantities' in x['metadata']:
customer_note = f"Grind: {x['metadata']['grind']}"
unit_price = convert_int_to_decimal(x['price']['unit_amount'])
items = map(format_product, x['metadata']['products_and_quantities'])
if x['description'] == 'Shipping':
shipping_cost = convert_int_to_decimal(x['amount'])
continue
# shipping_cost = find_shipping_cost(data_object['lines']['data'])
# items = find_products(data_object['lines']['data'])
# unit_price = find_unit_price(data_object['lines']['data'])
deserialize_subscription(data_object['lines']['data'])
order = Order.objects.create(
customer=,
status=,
billing_address=,
shipping_address=,
subtotal_amount=,
shipping_total=,
total_amount=data_object['total'],
weight=
)
order.lines.add(
[OrderLine(
product=item['product'],
quantity=item['quantity'],
customer_note='Grind: ',
unit_price=unit_price
) for item in items]
)
order.save()

View File

@ -4,6 +4,8 @@ import xmltodict
from lxml import etree
from usps import USPSApi as USPSApiBase
from django.conf import settings
from . import ShippingContainer
class USPSApi(USPSApiBase):
@ -37,3 +39,21 @@ class Rate:
etree.SubElement(package, 'Machinable').text = request['machinable']
self.result = usps.send_request('rate', xml)
def build_usps_rate_request(weight, container, zip_destination):
service = ShippingContainer.get_shipping_service_from_container(container)
return \
{
'service': service,
'zip_origination': settings.DEFAULT_ZIP_ORIGINATION,
'zip_destination': zip_destination,
'pounds': weight,
'ounces': '0',
'container': container,
'width': '',
'length': '',
'height': '',
'girth': '',
'machinable': 'TRUE'
}

View File

@ -0,0 +1,45 @@
{% extends "dashboard.html" %}
{% load static %}
{% block content %}
<article>
<header class="object__header">
<h1><img src="{% static "images/recurrent.png" %}" alt=""> Subscriptions</h1>
</header>
<section class="object__list">
<div class="object__item panel__header object__item--col5" href="subscription-detail">
<span>Date</span>
<span>Customer</span>
<span>Total</span>
</div>
{% for subscription in subscription_list %}
<a class="object__item object__item--link object__item--col5" href="{% url 'dashboard:subscription-detail' subscription.pk %}">
<span>{{subscription.created_at|date:"D, M j Y"}}</span>
<span>{{subscription.customer.get_full_name}}</span>
<span>${{subscription.total_amount}}</span>
</a>
{% empty %}
<span class="object__item">No subscriptions</span>
{% endfor %}
</section>
<section>
<div class="pagination">
<p class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</p>
</div>
</section>
</article>
{% endblock content %}

View File

@ -4,7 +4,9 @@
<article class="product">
<header class="object__header">
<h1>Update variant</h1>
<div>
<a href="{% url 'dashboard:variant-delete' product.pk variant.pk %}" class="action-button action-button--warning">Delete</a>
</div>
</header>
<section class="object__panel">
<form class="panel__item" method="POST" action="{% url 'dashboard:variant-update' product.pk variant.pk %}">

View File

@ -248,4 +248,35 @@ urlpatterns = [
name='customer-update'
),
])),
# Subscriptions
path('subscriptions/', include([
path(
'',
views.SubscriptionListView.as_view(),
name='subscription-list'
),
# path(
# 'new/',
# views.SubscriptionCreateView.as_view(),
# name='subscription-create'
# ),
# path('<int:pk>/', 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'
# ),
# ])),
])),
]

View File

@ -39,6 +39,7 @@ from core.models import (
Transaction,
TrackingNumber,
Coupon,
Subscription,
SiteSettings
)
@ -582,3 +583,31 @@ class CustomerUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
def get_success_url(self):
return reverse('dashboard:customer-detail', kwargs={'pk': self.object.pk})
class SubscriptionListView(LoginRequiredMixin, ListView):
model = Subscription
template_name = 'dashboard/subscription/list.html'
class SubscriptionCreateView(SuccessMessageMixin, CreateView):
model = Subscription
success_message = 'Subscription created.'
template_name_suffix = '_create_form'
fields = '__all__'
class SubscriptionDetailView(DetailView):
model = Subscription
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')

View File

@ -32,6 +32,7 @@ SENTRY_ENV = os.environ.get('SENTRY_ENV', 'development')
FACEBOOK_PIXEL_ID = os.environ.get('FACEBOOK_PIXEL_ID', '')
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', '')
STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY', '')
PAYPAL_CLIENT_ID = os.environ.get('PAYPAL_CLIENT_ID', '')
PAYPAL_SECRET_ID = os.environ.get('PAYPAL_SECRET_ID', '')

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,27 +1,72 @@
class Subscription {
static TWELVE_OZ
static SIXTEEN_OZ
static FIVE_LBS
class SubscriptionForm {
form
stripe_price_input
total_quantity_input
products_and_quantities
weight_per_item = '0'
weight_unit = 'lb'
products
max_quantity = 20
static TWELVE_SHIPPING
static SIXTEEN_SHIPPING
static FIVE_SHIPPING
constructor(form) {
this.form = document.querySelector(form)
this.stripe_price_input = this.form.querySelector('input[name=stripe_price_id]')
this.products = this.form.querySelectorAll('input[name^=product_]')
this.total_quantity_input = this.form.querySelector('input[name=total_quantity]')
this.total_weight_input = this.form.querySelector('input[name=total_weight]')
this.products_and_quantities = this.form.querySelector('input[name=products_and_quantities]')
constructor(element, output) {
this.TWELVE_OZ = '12'
this.SIXTEEN_OZ = '16'
this.FIVE_LBS = '75'
this.TWELVE_SHIPPING = 7
this.SIXTEEN_SHIPPING = 5
this.FIVE_SHIPPING = 1
this.connect()
}
this.element = element
this.output = this.element.querySelector('.output')
this.shippingDiscount = 10
this.price = this.element.querySelector('select[name=size]')
this.products = this.element.querySelectorAll('input[name^=product]')
this.element.addEventListener('change', this.render.bind(this))
this.render()
connect() {
this.form.addEventListener('change', this.change.bind(this))
const formData = new FormData(this.form)
for (const input of formData) {
console.log(input)
}
}
change(event) {
if (event.target.name == 'stripe_product') {
this.stripe_price_input.value = event.target.parentElement.querySelector('select[name=stripe_price]').value
const values = event.target.dataset.weight.split(':')
this.weight_per_item = values[0]
this.weight_unit = values[1]
} else if (event.target.name == 'stripe_price') {
this.stripe_price_input.value = event.target.value
} else if (event.target.name.includes('product_')) {
const selected = Array.from(this.products).filter(item => item.value > 0)
this.total_quantity_input.value = this.total_qty
this.products_and_quantities.value = JSON.stringify(
selected.map(item => {
return {'pk': item.dataset.id, 'name': item.name.slice(8), 'quantity': Number(item.value)}
})
)
}
this.total_weight_input.value = this.total_weight
this.checkMaxQuantity()
console.log(`${this.stripe_price_input.name}: ${this.stripe_price_input.value}`)
console.log(`${this.total_weight_input.name}: ${this.total_weight_input.value}`)
console.log(`${this.total_quantity_input.name}: ${this.total_quantity_input.value}`)
console.log(`${this.products_and_quantities.name}: ${this.products_and_quantities.value}`)
}
checkMaxQuantity() {
if (this.total_qty < this.max_quantity) {
Array.from(this.products).map(input => input.max = this.max_quantity)
} else {
Array.from(this.products).map(input => {
if (input.value == '') {
input.max = 0
} else {
input.max = input.value
}
})
}
}
get total_qty() {
@ -30,112 +75,12 @@ class Subscription {
}, 0)
}
get hasFreeShipping() {
switch(this.price.value) {
case this.TWELVE_OZ:
if (parseInt(this.total_qty) >= this.TWELVE_SHIPPING) {
return true
} else {
return false
}
break
case this.SIXTEEN_OZ:
if (parseInt(this.total_qty) >= this.SIXTEEN_SHIPPING) {
return true
} else {
return false
}
break
case this.FIVE_LBS:
if (parseInt(this.total_qty) >= this.FIVE_SHIPPING) {
return true
} else {
return false
}
break
default:
throw 'Something is wrong with the price'
get total_weight() {
const weight = this.total_qty * Number(this.weight_per_item)
return `${weight}:${this.weight_unit}`
}
}
get countToFreeShipping() {
switch(this.price.value) {
case this.TWELVE_OZ:
return this.TWELVE_SHIPPING - this.total_qty
break
case this.SIXTEEN_OZ:
return this.SIXTEEN_SHIPPING - this.total_qty
break
case this.FIVE_LBS:
return this.FIVE_SHIPPING
break
default:
throw 'Something is wrong with the price'
break
}
}
get shippingStatus() {
let items = 0
if (this.hasFreeShipping) {
return 'You have free shipping!'
} else {
return `Add ${this.countToFreeShipping} more item(s) for free shipping!`
}
}
get totalRetailPrice() {
let totalPrice = Array.from(this.products).reduce((total, current) => {
return total + (Number(this.price.value) * current.value);
}, 0);
return new Intl.NumberFormat('en-US', {
currency: 'USD',
style: 'currency',
}).format(totalPrice)
}
get totalPrice() {
let totalPrice = Array.from(this.products).reduce((total, current) => {
return total + (Number(this.price.value) * current.value);
}, 0);
let percentage = (this.shippingDiscount / 100) * totalPrice
return new Intl.NumberFormat('en-US', {
currency: 'USD',
style: 'currency',
}).format(totalPrice - percentage)
}
render() {
this.output.querySelector('.retail-price').innerText = this.totalRetailPrice
this.output.querySelector('.price').innerText = this.totalPrice
this.output.querySelector('.shipping').innerText = this.shippingStatus
}
add_item(item) {
this.items.push(item)
return this.items
}
createSubscription() {
fetch('/create-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priceId: priceId,
customerId: customerId,
}),
document.addEventListener('DOMContentLoaded', () => {
new SubscriptionForm('.subscription-create-form')
})
}
}
const subCreateFromEl = document.querySelector('.subscription-create-form')
const sub = new Subscription(subCreateFromEl)

View File

@ -0,0 +1,154 @@
class SubscriptionForm {
TWELVE_OZ = '12'
SIXTEEN_OZ = '16'
FIVE_LBS = '75'
TWELVE_SHIPPING = 7
SIXTEEN_SHIPPING = 5
FIVE_SHIPPING = 1
max_quantity = 20
form = null
products = null
output = null
price = null
productsAndQuantities = null
constructor(form, output) {
this.form = form
this.productsAndQuantities = this.form.querySelector('[name=products_quantities]')
this.output = this.form.querySelector('.output')
this.shippingDiscount = 10
this.price = this.form.querySelector('select[name=size]')
this.products = this.form.querySelectorAll('input[name^=product_]')
this.form.addEventListener('change', this.render.bind(this))
this.render()
}
get total_qty() {
return Array.from(this.products).reduce((total, current) => {
return total + Number(current.value)
}, 0)
}
get hasFreeShipping() {
switch(this.price.value) {
case this.TWELVE_OZ:
if (parseInt(this.total_qty) >= this.TWELVE_SHIPPING) {
return true
} else {
return false
}
break
case this.SIXTEEN_OZ:
if (parseInt(this.total_qty) >= this.SIXTEEN_SHIPPING) {
return true
} else {
return false
}
break
case this.FIVE_LBS:
if (parseInt(this.total_qty) >= this.FIVE_SHIPPING) {
return true
} else {
return false
}
break
default:
throw 'Something is wrong with the price'
}
}
get countToFreeShipping() {
switch(this.price.value) {
case this.TWELVE_OZ:
return this.TWELVE_SHIPPING - this.total_qty
break
case this.SIXTEEN_OZ:
return this.SIXTEEN_SHIPPING - this.total_qty
break
case this.FIVE_LBS:
return this.FIVE_SHIPPING
break
default:
throw 'Something is wrong with the price'
break
}
}
get shippingStatus() {
let items = 0
if (this.hasFreeShipping) {
return 'You have free shipping!'
} else {
return `Add ${this.countToFreeShipping} more item(s) for free shipping!`
}
}
get totalRetailPrice() {
let totalPrice = Array.from(this.products).reduce((total, current) => {
return total + (Number(this.price.value) * current.value);
}, 0);
return new Intl.NumberFormat('en-US', {
currency: 'USD',
style: 'currency',
}).format(totalPrice)
}
get totalPrice() {
let totalPrice = Array.from(this.products).reduce((total, current) => {
return total + (Number(this.price.value) * current.value);
}, 0);
let percentage = (this.shippingDiscount / 100) * totalPrice
return new Intl.NumberFormat('en-US', {
currency: 'USD',
style: 'currency',
}).format(totalPrice - percentage)
}
render(event) {
this.output.querySelector('.retail-price').innerText = this.totalRetailPrice
this.output.querySelector('.price').innerText = this.totalPrice
this.output.querySelector('.shipping').innerText = this.shippingStatus
this.updateSelected()
if (this.total_qty < this.max_quantity) {
Array.from(this.products).map(input => input.max = this.max_quantity)
} else {
Array.from(this.products).map(input => {
if (input.value == '') {
input.max = 0
} else {
input.max = input.value
}
})
}
}
updateSelected() {
const selected = Array.from(this.products).filter(item => item.value > 0)
this.productsAndQuantities.value = selected.map(item => `${item.name.slice(8)}:${item.value}`).join(',')
console.log(this.productsAndQuantities.value)
}
createSubscription() {
fetch('/create-subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priceId: priceId,
customerId: customerId,
}),
})
}
}
document.addEventListener('DOMContentLoaded', () => {
new SubscriptionForm(document.querySelector('.subscription-create-form'))
})

View File

@ -210,6 +210,13 @@ input[type=submit]:hover,
background-color: var(--yellow-alt-color);
}
button:disabled,
input[type=submit]:disabled,
.action-button:disabled {
background-color: var(--yellow-alt-color);
cursor: no-drop;
}
.errorlist {
background-color: var(--red-color);
color: white;
@ -756,25 +763,53 @@ article + article {
}
.subscription-create-form {
display: grid;
grid-template-columns: 2fr 1fr;
.subscription-authenticate {
text-align: center;
}
.subscription-create-form section {
margin-bottom: 3rem;
}
.subscription-coffee {
display: flex;
gap: 2rem;
overflow-x: scroll;
padding: 1rem;
border-right: var(--default-border);
border-left: var(--default-border);
}
.product__subscription-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
.subscription-coffee div {
text-align: center;
}
.subscription-coffee img {
max-height: 300px;
}
.subscription-products {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
justify-items: center;
gap: 6rem;
overflow-y: scroll;
max-height: 50rem;
border-bottom: var(--default-border);
padding: 0 2rem 2rem;
}
.product__subscription-list div {
/*max-width: 10rem;*/
.subscription-products > div {
border: var(--default-border);
width: 100%;
box-sizing: border-box;
padding: 1rem;
text-align: center;
}
.subscription-products .product-prices {
visibility: hidden;
}
.subscription-products input[type=radio]:checked ~ .product-prices {
visibility: visible;
}

View File

@ -24,10 +24,9 @@ from core import (
ShippingService,
ShippingProvider,
ShippingContainer,
CoffeeGrind,
build_usps_rate_request
CoffeeGrind
)
from core.usps import build_usps_rate_request
from .forms import CartItemUpdateForm
from .payments import CreateOrder
@ -134,10 +133,6 @@ class Cart:
}
def deserialize(self, data):
# Transform old cart
if type(data) is list:
return
try:
self.coupon = Coupon.objects.get(code=data.get('coupon_code'))
except Coupon.DoesNotExist:
@ -220,16 +215,19 @@ class Cart:
if item.variant.product in self.coupon.products.all():
yield item.total_price
def get_shipping_container_choices(self):
def get_shipping_container_choices(self, total_weight=None):
if total_weight is None:
total_weight = self.total_weight
is_selectable = Q(
is_selectable=True
)
min_weight_matched = Q(
min_order_weight__lte=self.total_weight) | Q(
min_order_weight__lte=total_weight) | Q(
min_order_weight__isnull=True
)
max_weight_matched = Q(
max_order_weight__gte=self.total_weight) | Q(
max_order_weight__gte=total_weight) | Q(
max_order_weight__isnull=True
)
containers = ShippingRate.objects.filter(

View File

@ -10,8 +10,9 @@ from django.core.exceptions import ValidationError
from localflavor.us.us_states import USPS_CHOICES
from usps import USPSApi, Address
from captcha.fields import CaptchaField
from django_measurement.forms import MeasurementField
from core.models import Order, ProductVariant
from core.models import Order, ProductVariant, Subscription
from core import CoffeeGrind, ShippingContainer
logger = logging.getLogger(__name__)
@ -39,21 +40,6 @@ class CartItemUpdateForm(forms.Form):
quantity = forms.IntegerField(min_value=1, max_value=20, initial=1)
class AddToSubscriptionForm(forms.Form):
SEVEN_DAYS = 7
FOURTEEN_DAYS = 14
THIRTY_DAYS = 30
SCHEDULE_CHOICES = [
(SEVEN_DAYS, 'Every 7 days'),
(FOURTEEN_DAYS, 'Every 14 days'),
(THIRTY_DAYS, 'Every 30 days'),
]
quantity = forms.IntegerField(min_value=1, initial=1)
grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES)
schedule = forms.ChoiceField(choices=SCHEDULE_CHOICES)
class AddressForm(forms.Form):
full_name = forms.CharField()
email = forms.EmailField()
@ -134,53 +120,20 @@ class CouponApplyForm(forms.Form):
code = forms.CharField(label='Coupon code')
class ContactForm(forms.Form):
GOOGLE = 'Google Search'
SHOP = 'The coffee shop'
WOM = 'Word of mouth'
PRODUCT = 'Coffee Bag'
STORE = 'Store'
OTHER = 'Other'
REFERAL_CHOICES = [
(GOOGLE, 'Google Search'),
(SHOP, '"Better Living Through Coffee" coffee shop'),
(WOM, 'Friend/Relative'),
(PRODUCT, 'Our Coffee Bag'),
(STORE, 'PT Food Coop/other store'),
(OTHER, 'Other (please describe below)'),
class SubscriptionForm(forms.Form):
GRIND_CHOICES = [
('Whole Beans', 'Whole Beans'),
('Espresso', 'Espresso'),
('Cone Drip', 'Cone Drip'),
('Basket Drip', 'Basket Drip'),
('French Press', 'French Press'),
('Stovetop Espresso (Moka Pot)', 'Stovetop Espresso (Moka Pot)'),
('AeroPress', 'AeroPress'),
('Percolator', 'Percolator'),
('BLTC cafe pour over', 'BLTC cafe pour over')
]
full_name = forms.CharField()
email_address = forms.EmailField()
referal = forms.ChoiceField(
label='How did you find our website?',
choices=REFERAL_CHOICES
)
subject = forms.CharField()
message = forms.CharField(widget=forms.Textarea)
captcha = CaptchaField()
class SubscriptionCreateForm(forms.Form):
SEVEN_DAYS = 7
FOURTEEN_DAYS = 14
THIRTY_DAYS = 30
SCHEDULE_CHOICES = [
(SEVEN_DAYS, 'Every 7 days'),
(FOURTEEN_DAYS, 'Every 14 days'),
(THIRTY_DAYS, 'Every 30 days'),
]
TWELVE_OZ = 12
SIXTEEN_OZ = 16
FIVE_LBS = 75
SIZE_CHOICES = [
(TWELVE_OZ, '12 oz ($10.80)'),
(SIXTEEN_OZ, '16 oz ($14.40)'),
(FIVE_LBS, '5 lbs ($67.50)'),
]
grind = forms.ChoiceField(choices=CoffeeGrind.GRIND_CHOICES)
schedule = forms.ChoiceField(choices=SCHEDULE_CHOICES)
size = forms.ChoiceField(choices=SIZE_CHOICES)
grind = forms.ChoiceField(choices=GRIND_CHOICES, label='')
products_and_quantities = forms.CharField(widget=forms.HiddenInput())
stripe_price_id = forms.CharField(widget=forms.HiddenInput())
total_quantity = forms.IntegerField(widget=forms.HiddenInput())
total_weight = forms.CharField(widget=forms.HiddenInput())

View File

@ -1,112 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="https://js.stripe.com/v3/"></script>
<script>const stripe = Stripe({{ STRIPE_API_KEY }});</script>
<script src="{% static 'scripts/subscriptions.js' %}" defer></script>
{% endblock %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<section>
<form id="payment-form">
<div id="payment-element">
<!-- Elements will create form elements here -->
</div>
<button id="submit">Subscribe</button>
<div id="error-message">
<!-- Display error message to your customers here -->
</div>
</form>
</section>
</article>
<script>
const appearance = {
theme: 'flat',
variables: {
fontFamily: ' "Inter", sans-serif',
fontLineHeight: '1.75',
borderRadius: '10px',
colorBackground: '#fffbf8',
colorPrimaryText: '#34201a'
},
rules: {
'.Block': {
backgroundColor: 'var(--colorBackground)',
boxShadow: 'none',
padding: '12px'
},
'.Input': {
padding: '12px'
},
'.Input:disabled, .Input--invalid:disabled': {
color: 'lightgray'
},
'.Tab': {
padding: '10px 12px 8px 12px',
border: 'none'
},
'.Tab:hover': {
border: 'none',
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 7px rgba(18, 42, 66, 0.04)'
},
'.Tab--selected, .Tab--selected:focus, .Tab--selected:hover': {
border: 'none',
backgroundColor: '#fff',
boxShadow: '0 0 0 1.5px var(--colorPrimaryText), 0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 7px rgba(18, 42, 66, 0.04)'
},
'.Label': {
fontWeight: '500'
}
}
};
// Pass the appearance object to the Elements instance
const elements = stripe.elements({clientSecret, appearance});
const options = {
clientSecret: '{{ CLIENT_SECRET }}',
// Fully customizable with appearance API.
appearance: {/*...*/},
};
// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in step 5
const elements = stripe.elements(options);
// Create and mount the Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const {error} = await stripe.confirmPayment({
//`Elements` instance that was used to create the Payment Element
elements,
confirmParams: {
return_url: "https://example.com/order/123/complete",
}
});
if (error) {
// This point will only be reached if there is an immediate error when
// confirming the payment. Show error to your customer (for example, payment
// details incomplete)
const messageContainer = document.querySelector('#error-message');
messageContainer.textContent = error.message;
} else {
// Your customer will be redirected to your `return_url`. For some payment
// methods like iDEAL, your customer will be redirected to an intermediate
// site first to authorize the payment, then redirected to the `return_url`.
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Subscription | {% endblock %}
{% block content %}
<article>
<header>
<h1>Subscription</h1>
</header>
<section>
<h3>Shipping Address</h3>
<form class="checkout__address-form" action="" method="POST">
{% csrf_token %}
{{form.as_p}}
<p>
<input type="submit" value="Continue">
</p>
</form>
<p>We validate addresses with USPS, if you are having issues please contact us at <a href="mailto:support@ptcoffee.com">support@ptcoffee.com</a>.</p>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<h1>Review your subscription</h1>
Shipping address
<dl>
<dt>Items:</dt>
<dd>{{ subscription.items }}</dd>
<dt>Metadata:</dt>
<dd>{{ subscription.metadata }}</dd>
<dt>Address:</dt>
<dd>{{ subscription.shipping_address }}</dd>
<dt>Weight:</dt>
<dd>{{ subscription.total_weight }}</dd>
<dt>Created At:</dt>
<dd>{{ subscription.created_at }}</dd>
</dl>
<form action="{% url 'storefront:subscription-confirmation' subscription.pk %}" class="subscription-confirmation-form" method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Continue to payment">
</form>
</article>
<script>
import { getCookie } from "./cookie.js"
class Form {
form
constructor(form) {
this.form = document.querySelector(form)
this.connect()
}
connect() {
this.form.addEventListener('submit', this.submit.bind(this))
}
fetch() {
const formData = new FormData(form)
// get the csrftoken
const csrftoken = getCookie("csrftoken")
const options = {
method: "POST",
body: JSON.stringify(Object.fromEntries(formData)),
mode: "same-origin",
};
// construct a new Request passing in the csrftoken
const request = new Request(this.form.action, {
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
})
return fetch(request, options)
.then((response) => response.json())
.then((subscription) => subscription.id)
}
submit(event) {
event.preventDefault()
response = this.fetch.bind(this)
console.log(response)
}
}
document.addEventListener('DOMContentLoaded', () => {
new Form('.subscription-confirmation-form')
})
</script>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="{% static 'scripts/subscriptions.js' %}" defer></script>
{% endblock %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<header>
<h1>Subscription</h1>
</header>
<section class="checkout__address">
<h3>Shipping address</h3>
<p>{{shipping_address.email}}</p>
<address>
{{shipping_address.first_name}}
{{shipping_address.last_name}}<br>
{{shipping_address.street_address_1}}<br>
{% if shipping_address.street_address_2 %}
{{shipping_address.street_address_2}}<br>
{% endif %}
{{shipping_address.city}}, {{shipping_address.state}}, {{shipping_address.postal_code}}
</address>
<a class="action-button" href="{% url 'storefront:subscription-address' %}">Change</a>
</section>
<section class="cart__list">
<h3>Items</h3>
<h4>Size: {{ sub_cart.size }}</h4>
<h4>Grind: {{ sub_cart.grind }}</h4>
{% for item in sub_cart.items %}
<div class="cart__item">
{% with product=item.product %}
<figure>
<img src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
</figure>
<div>
<h2>{{ item.quantity }} &times; {{product.name}}</h2>
</div>
{% endwith %}
</div>
{% endfor %}
</section>
<section class="cart__summary">
<h3>Subscription summary</h3>
<div class="cart__table-wrapper">
<table class="cart__totals">
<tr>
<td>Subtotal</td>
<td>{{ sub_cart.subtotal_price }}</td>
</tr>
<tr>
<td>Shipping</td>
<td>${{ sub_cart.shipping_cost }}</td>
</tr>
<tr>
<th>Total</th>
<td><strong>${{ sub_cart.total_price }}</strong></td>
</tr>
<tr>
<th>Schedule</th>
<td><strong>{{ sub_cart.schedule }}</strong></td>
</tr>
</table>
</div>
<form action="{% url 'storefront:subscription-create' %}" method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Checkout">
</form>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="https://js.stripe.com/v3/"></script>
{% endblock %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<section>
<h1>Success! Subscription created.</h1>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="{% static 'scripts/subscriptions.js' %}" defer></script>
{% endblock %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<section>
<form action="{% url 'storefront:subscription-form' %}" method="POST" class="subscription-create-form">
{% csrf_token %}
<h4>Pick your coffee:</h4>
<section class="subscription-coffee">
{% for product in product_list %}
<div>
<label><strong>{{ product }}</strong>
<figure class="product__figure">
<img class="product__image" src="{{ product.get_first_img.image.url }}">
</figure>
</label>
<label>Quantity:</label>
<input type="number" min="0" max="20" data-id="{{ product.pk }}" name="product_{{ product.name }}">
</div>
{% endfor %}
</section>
<h4>Pick your grind:</h4>
<section>
{{ form.as_p }}
</section>
<h4>Pick your size:</h4>
<section class="subscription-products">
{% for product in stripe_products %}
<div>
<input data-weight="{{ product.weight_per_item }}" type="radio" name="stripe_product" id="{{ product.id }}" value="{{ product.id }}">
<label for="{{ product.id }}"><h5>{{ product.name }}</h5></label><br>
<h5>{{ product.cost }} / bag</h5>
<div class="product-prices">
<h5>Pick your Schedule:</h5>
<select name="stripe_price">
{% for price in product.prices %}
<option value="{{ price.id }}">Every {{ price.interval_count }} / {{ price.interval }}{{ price.interval_count|pluralize }}</option>
{% endfor %}
</select>
</div>
</div>
{% endfor %}
</section>
<section>
<table class="subscription-totals"></table>
<p>
<input type="submit" value="Continue">
</p>
</section>
</form>
</section>
</article>
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="https://js.stripe.com/v3/"></script>
<script>const stripe = Stripe('{{ stripe_publishable_key }}');</script>
{% endblock %}
{% block content %}
<div class="site__banner site__banner--site">
<h1>Subscriptions</h1>
<h4>SUBSCRIBE AND SAVE</h4>
</div>
<article>
<form id="payment-form">
<div id="payment-element">
<!-- Elements will create form elements here -->
</div>
<button id="submit">Subscribe</button>
<div id="error-message">
<!-- Display error message to your customers here -->
</div>
</form>
</article>
<script>
const options = {
clientSecret: '{{ client_secret }}',
// Fully customizable with appearance API.
appearance: {/*...*/},
};
// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in step 5
const elements = stripe.elements(options);
// Create and mount the Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const {error} = await stripe.confirmPayment({
//`Elements` instance that was used to create the Payment Element
elements,
confirmParams: {
return_url: '{{ return_url }}',
}
});
if (error) {
// This point will only be reached if there is an immediate error when
// confirming the payment. Show error to your customer (for example, payment
// details incomplete)
const messageContainer = document.querySelector('#error-message');
messageContainer.textContent = error.message;
} else {
// Your customer will be redirected to your `return_url`. For some payment
// methods like iDEAL, your customer will be redirected to an intermediate
// site first to authorize the payment, then redirected to the `return_url`.
}
});
</script>
{% endblock %}

View File

@ -14,24 +14,25 @@
</div>
<article>
<section>
{% for product in stripe_products %}
{{ product }}
{% endfor %}
</section>
<section class="">
<form action="" class="subscription-create-form">
<form method="post" class="subscription-create-form">
{% csrf_token %}
<div>
<h4>Pick your coffee</h4>
<div class="product__subscription-list">
{% for product in product_list %}
<div>
<label for="">{{ product.name }}
<label>{{ product }}
<figure class="product__figure">
<img class="product__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
</figure>
</label>
<label for="">Quantity</label>
<label>Schedule</label>
<select name="schedule">
{% for var1 in iterable %}
{% endfor %}
</select>
<label>Quantity</label>
<input type="number" min="0" max="20" name="product_{{ product.pk }}">
</div>
{% endfor %}

View File

@ -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/<int:pk>/',
@ -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('<int:pk>/', 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'
),
])),
])),
]

View File

@ -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'],
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
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')
)
# 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)
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 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,
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'
},
)
return JsonResponse({
'clientSecret': intent['client_secret']
'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'})

View File

@ -49,7 +49,7 @@
{% for category in category_list %}
<li><a class="nav__link" href="{% url 'storefront:category-detail' category.pk %}">{{ category }}</a></li>
{% endfor %}
<!-- <li><a class="nav__link" href="{% url 'storefront:subscriptions' %}">Subscriptions</a></li> -->
<li><a class="nav__link" href="{% url 'storefront:subscription-form' %}">Subscriptions</a></li>
<li><a class="nav__link" href="{% url 'storefront:fair-trade' %}">Fair trade</a></li>
<li><a class="nav__link" href="{% url 'storefront:reviews' %}">Reviews</a></li>
<li><a class="nav__link" href="{% url 'storefront:about' %}">About</a></li>

View File

@ -41,6 +41,10 @@
<img src="{% static 'images/box.png' %}" alt="">
Orders
</a>
<a href="{% url 'dashboard:subscription-list' %}">
<img src="{% static 'images/recurrent.png' %}" alt="">
Subscriptions
</a>
<a href="{% url 'dashboard:customer-list' %}">
<img src="{% static 'images/customer.png' %}" alt="">
Customers

225
subscription_object.py Normal file
View File

@ -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,
}