Merge branch 'feature/coffee-subscriptions' into develop

This commit is contained in:
Nathan Chapman 2023-01-21 02:51:00 -07:00
commit 510dcd1462
54 changed files with 2577 additions and 883 deletions

894
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

34
bens_account.json Normal file
View File

@ -0,0 +1,34 @@
[{
"model": "accounts.address",
"pk": 3,
"fields": {
"first_name": "Ben",
"last_name": "Cook",
"street_address_1": "1072 CENTER ST",
"street_address_2": "",
"city": "PORT TOWNSEND",
"state": "WA",
"postal_code": "98368"
}
}, {
"model": "accounts.user",
"pk": 2,
"fields": {
"password": "pbkdf2_sha256$320000$wptMSeHwCSQfTiGuZkoRgB$p4c6DXM5fCBC5oRHVIKm5IaryYGIRnacSN0w0oTim/U=",
"last_login": "2022-12-29T12:43:47.411Z",
"is_superuser": false,
"username": "bencook99@gmail.com",
"first_name": "Benjamin",
"last_name": "Cook",
"email": "bencook99@gmail.com",
"is_staff": true,
"is_active": true,
"date_joined": "2022-05-14T09:32:20Z",
"default_shipping_address": 3,
"default_billing_address": null,
"stripe_id": "",
"groups": [],
"user_permissions": [],
"addresses": [3]
}
}]

View File

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

View File

@ -1,3 +1,4 @@
import stripe
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
@ -17,6 +18,18 @@ class Address(models.Model):
) )
postal_code = models.CharField(max_length=20, blank=True) 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): def __str__(self):
return f""" return f"""
{self.first_name} {self.last_name} {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 Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL
) )
stripe_id = models.CharField(max_length=255, blank=True) 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 from .models import Address, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
@receiver(post_save, sender=User, dispatch_uid='user_saved') @receiver(post_save, sender=User, dispatch_uid='user_saved')
def user_saved(sender, instance, created, **kwargs): def user_saved(sender, instance, created, **kwargs):
logger.info('User was saved') logger.info('User was saved')
if created or not instance.stripe_id: if created or not instance.stripe_id:
stripe.api_key = settings.STRIPE_API_KEY instance.get_or_create_stripe_id()
response = stripe.Customer.create(
name=instance.first_name + instance.last_name
)
instance.stripe_id = response['id']
instance.save()

View File

@ -4,7 +4,7 @@ from .models import Address, User
from .tasks import send_account_created_email 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( address, a_created = Address.objects.get_or_create(
first_name=shipping_address['first_name'], first_name=shipping_address['first_name'],
last_name=shipping_address['last_name'], last_name=shipping_address['last_name'],

View File

@ -190,21 +190,3 @@ class CoffeeGrind:
(PERCOLATOR, 'Percolator'), (PERCOLATOR, 'Percolator'),
(CAFE_STYLE, 'BLTC cafe pour over') (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, ShippingRate,
Order, Order,
Transaction, Transaction,
Subscription,
OrderLine, OrderLine,
) )
@ -24,4 +25,5 @@ admin.site.register(Coupon)
admin.site.register(ShippingRate) admin.site.register(ShippingRate)
admin.site.register(Order) admin.site.register(Order)
admin.site.register(Transaction) admin.site.register(Transaction)
admin.site.register(Subscription)
admin.site.register(OrderLine) admin.site.register(OrderLine)

View File

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

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-12-30 16:04
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0026_orderline_product'),
]
operations = [
migrations.AddField(
model_name='order',
name='subscription',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='core.subscription'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.2 on 2022-12-30 21:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0027_order_subscription'),
]
operations = [
migrations.AddField(
model_name='order',
name='subscription_description',
field=models.CharField(blank=True, max_length=500),
),
]

View File

@ -1,4 +1,5 @@
import logging import logging
import json
from decimal import Decimal from decimal import Decimal
from PIL import Image from PIL import Image
from measurement.measures import Weight from measurement.measures import Weight
@ -24,9 +25,9 @@ from . import (
TransactionStatus, TransactionStatus,
OrderStatus, OrderStatus,
ShippingProvider, ShippingProvider,
ShippingContainer, ShippingContainer
build_usps_rate_request
) )
from .usps import build_usps_rate_request
from .weight import WeightUnits, zero_weight from .weight import WeightUnits, zero_weight
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -166,7 +167,7 @@ class ProductVariant(models.Model):
) )
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sku = models.CharField(max_length=255, blank=True) sku = models.CharField(max_length=255, blank=True)
stripe_id = models.CharField(max_length=255, blank=True) stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
price = models.DecimalField( price = models.DecimalField(
max_digits=settings.DEFAULT_MAX_DIGITS, max_digits=settings.DEFAULT_MAX_DIGITS,
decimal_places=settings.DEFAULT_DECIMAL_PLACES, decimal_places=settings.DEFAULT_DECIMAL_PLACES,
@ -197,6 +198,9 @@ class ProductVariant(models.Model):
class Meta: class Meta:
ordering = ['sorting', 'weight'] ordering = ['sorting', 'weight']
indexes = [
models.Index(fields=['stripe_id'])
]
class ProductOption(models.Model): class ProductOption(models.Model):
@ -378,6 +382,14 @@ class Order(models.Model):
blank=True, blank=True,
null=True null=True
) )
subscription = models.ForeignKey(
'Subscription',
related_name='orders',
editable=False,
null=True,
on_delete=models.SET_NULL
)
subscription_description = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True, editable=False) created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -438,6 +450,13 @@ class OrderLine(models.Model):
editable=False, editable=False,
on_delete=models.CASCADE on_delete=models.CASCADE
) )
product = models.ForeignKey(
Product,
related_name='order_lines',
on_delete=models.SET_NULL,
blank=True,
null=True,
)
variant = models.ForeignKey( variant = models.ForeignKey(
ProductVariant, ProductVariant,
related_name='order_lines', related_name='order_lines',
@ -500,14 +519,109 @@ class TrackingNumber(models.Model):
return self.tracking_id return self.tracking_id
class SubscriptionManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_active=True)
class Subscription(models.Model): class Subscription(models.Model):
stripe_id = models.CharField(max_length=255, blank=True) stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
customer = models.OneToOneField( customer = models.ForeignKey(
User, User,
related_name='subscription', related_name='subscriptions',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True 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 convert_int_to_decimal(self, price):
return Decimal(str(price)[:-2] + '.' + str(price)[-2:])
def format_product(self, data):
return {
'product': Product.objects.get(pk=data['pk']),
'quantity': data['quantity']
}
def deserialize_subscription(self, data):
subscription = {}
for x in data:
if 'Coffee' in x['description']:
subscription['unit_price'] = self.convert_int_to_decimal(x['price']['unit_amount'])
subscription['description'] = x['description']
if 'Shipping' in x['description']:
subscription['shipping_cost'] = self.convert_int_to_decimal(x['amount'])
return subscription
def create_order(self, data_object):
subscription = self.deserialize_subscription(data_object['lines']['data'])
subscription['items'] = map(self.format_product, self.metadata['products_and_quantities'])
subscription['customer_note'] = f"Grind: {self.metadata['grind']}"
order = Order.objects.create(
customer=self.customer,
status=OrderStatus.UNFULFILLED,
shipping_address=self.shipping_address,
subtotal_amount=self.convert_int_to_decimal(
data_object['subtotal']) - subscription['shipping_cost'],
shipping_total=subscription['shipping_cost'],
total_amount=self.convert_int_to_decimal(data_object['total']),
weight=self.total_weight,
subscription=self,
subscription_description=subscription['description']
)
bulk_lines = [OrderLine(
order=order,
product=item['product'],
quantity=item['quantity'],
customer_note=subscription['customer_note'],
unit_price=subscription['unit_price']
) for item in subscription['items']]
OrderLine.objects.bulk_create(bulk_lines)
def format_metadata(self):
metadata = {}
for key, value in self.metadata.items():
if 'products_and_quantities' in key:
metadata[key] = json.dumps(value)
else:
metadata[key] = 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): 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__) logger = logging.getLogger(__name__)
stripe.api_key = settings.STRIPE_API_KEY
# @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)
# )
@receiver(post_save, sender=Order, dispatch_uid="order_created") @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 pk=instance.order.pk
)[0] )[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.save()
# order.update( # order.update(

View File

@ -0,0 +1,74 @@
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 deserialize_subscription(data):
sub_data = {}
for x in data:
if 'products_and_quantities' in x['metadata']:
sub_data['customer_note'] = f"Grind: {x['metadata']['grind']}"
sub_data['unit_price'] = convert_int_to_decimal(x['price']['unit_amount'])
sub_data['items'] = map(format_product, x['metadata']['products_and_quantities'])
sub_data['total_weight'] = x['metadata']['total_weight']
if x['description'] == 'Shipping':
sub_data['shipping_cost'] = convert_int_to_decimal(x['amount'])
continue
return sub_data
# 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'])
sub_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=sub_data['customer_note'],
unit_price=sub_data['unit_price']
) for item in sub_data['items']]
)
order.save()

View File

@ -4,6 +4,8 @@ import xmltodict
from lxml import etree from lxml import etree
from usps import USPSApi as USPSApiBase from usps import USPSApi as USPSApiBase
from django.conf import settings
from . import ShippingContainer
class USPSApi(USPSApiBase): class USPSApi(USPSApiBase):
@ -37,3 +39,21 @@ class Rate:
etree.SubElement(package, 'Machinable').text = request['machinable'] etree.SubElement(package, 'Machinable').text = request['machinable']
self.result = usps.send_request('rate', xml) 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

@ -13,6 +13,11 @@
<span class="order__status order__status--{{order.status}}">{{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}})</span> <span class="order__status order__status--{{order.status}}">{{order.get_status_display}} ({{order.total_quantity_fulfilled}} / {{order.total_quantity_ordered}})</span>
</div> </div>
</header> </header>
{% if order.subscription %}
<section>
<h3>Subscription: {{ order.subscription_description }}</h3><br>
</section>
{% endif %}
<section class="object__list"> <section class="object__list">
<div class="object__item panel__header object__item--col5"> <div class="object__item panel__header object__item--col5">
<span>Product</span> <span>Product</span>
@ -23,20 +28,37 @@
</div> </div>
{% for item in order.lines.all %} {% for item in order.lines.all %}
<div class="object__item object__item--col5"> <div class="object__item object__item--col5">
{% with product=item.variant.product %} {% if item.variant %}
<figure class="item__figure"> {% with product=item.variant.product %}
{% if item.variant.image %} <figure class="item__figure">
<img class="item__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}"> {% if item.variant.image %}
{% else %} <img class="item__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
<img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> {% else %}
{% endif %} <img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
<figcaption><strong>{{item.variant}}</strong><br>{{item.customer_note}}</figcaption> {% endif %}
</figure> <figcaption><strong>{{item.variant}}</strong><br>{{item.customer_note}}</figcaption>
<span>{{product.sku}}</span> </figure>
<span>{{item.quantity}}</span> <span>{{product.sku}}</span>
<span>${{item.unit_price}}</span> <span>{{item.quantity}}</span>
<span>${{item.get_total}}</span> <span>${{item.unit_price}}</span>
{% endwith %} <span>${{item.get_total}}</span>
{% endwith %}
{% elif item.product %}
{% with product=item.product %}
<figure class="item__figure">
{% if item.variant.image %}
<img class="item__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="item__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{item.product}}</strong><br>{{item.customer_note}}</figcaption>
</figure>
<span>{{product.sku}}</span>
<span>{{item.quantity}}</span>
<span>${{item.unit_price}}</span>
<span>${{item.get_total}}</span>
{% endwith %}
{% endif %}
</div> </div>
{% empty %} {% empty %}
<p>No items in order yet.</p> <p>No items in order yet.</p>
@ -114,15 +136,26 @@
</div> </div>
</section> </section>
<section class="object__panel"> {% if order.subscription %}
<div class="object__item panel__header"> <section class="object__panel">
<h4>Transaction</h4> <div class="object__item panel__header">
</div> <h4>Subscription</h4>
<div class="panel__item"> </div>
<p>PayPal transaction ID: <strong>{{order.transaction.paypal_id}}</strong><br> <div class="panel__item">
Status: <strong>{{order.transaction.get_status_display}}</strong> <p>{{ order.subscription.stripe_id }}</p>
</p> </div>
</div> </section>
</section> {% else %}
<section class="object__panel">
<div class="object__item panel__header">
<h4>Transaction</h4>
</div>
<div class="panel__item">
<p>PayPal transaction ID: <strong>{{order.transaction.paypal_id}}</strong><br>
Status: <strong>{{order.transaction.get_status_display}}</strong>
</p>
</div>
</section>
{% endif %}
</article> </article>
{% endblock content %} {% endblock content %}

View File

@ -23,16 +23,37 @@
</div> </div>
{% for form in form %} {% for form in form %}
<div class="object__item object__item--col4"> <div class="object__item object__item--col4">
{% with product=form.instance.variant.product %} {% if form.instance.variant %}
{{form.id}} {% with product=form.instance.variant.product %}
<figure class="item__figure"> {{form.id}}
<img class="product__image product__image--small" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <figure class="item__figure">
<figcaption><strong>{{form.instance.variant}}</strong></figcaption> {% if item.variant.image %}
</figure> <img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
<span>{{product.sku}}</span> {% else %}
<span>{{form.quantity_fulfilled}} / {{form.instance.quantity}}</span> <img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
<span>{{form.instance.customer_note}}</span> {% endif %}
{% endwith %} <figcaption><strong>{{form.instance.variant}}</strong></figcaption>
</figure>
<span>{{product.sku}}</span>
<span>{{form.quantity_fulfilled}} / {{form.instance.quantity}}</span>
<span>{{form.instance.customer_note}}</span>
{% endwith %}
{% elif form.instance.product %}
{% with product=form.instance.product %}
{{form.id}}
<figure class="item__figure">
{% if item.variant.image %}
<img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
<figcaption><strong>{{form.instance.product}}</strong></figcaption>
</figure>
<span>{{product.sku}}</span>
<span>{{form.quantity_fulfilled}} / {{form.instance.quantity}}</span>
<span>{{form.instance.customer_note}}</span>
{% endwith %}
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
<div class="object__item object__item--col5"> <div class="object__item object__item--col5">

View File

@ -16,7 +16,7 @@
</div> </div>
{% for order in order_list %} {% for order in order_list %}
<a class="object__item object__item--link object__item--col5" href="{% url 'dashboard:order-detail' order.pk %}"> <a class="object__item object__item--link object__item--col5" href="{% url 'dashboard:order-detail' order.pk %}">
<span>#{{order.pk}}</span> <span>#{{order.pk}} {% if order.subscription %}(subscription){% endif %}</span>
<span>{{order.created_at|date:"D, M j Y"}}</span> <span>{{order.created_at|date:"D, M j Y"}}</span>
<span>{{order.customer.get_full_name}}</span> <span>{{order.customer.get_full_name}}</span>
<span class="order__status--display"> <span class="order__status--display">

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"> <article class="product">
<header class="object__header"> <header class="object__header">
<h1>Update variant</h1> <h1>Update variant</h1>
<a href="{% url 'dashboard:variant-delete' product.pk variant.pk %}" class="action-button action-button--warning">Delete</a> <div>
<a href="{% url 'dashboard:variant-delete' product.pk variant.pk %}" class="action-button action-button--warning">Delete</a>
</div>
</header> </header>
<section class="object__panel"> <section class="object__panel">
<form class="panel__item" method="POST" action="{% url 'dashboard:variant-update' product.pk variant.pk %}"> <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' 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, Transaction,
TrackingNumber, TrackingNumber,
Coupon, Coupon,
Subscription,
SiteSettings SiteSettings
) )
@ -582,3 +583,31 @@ class CustomerUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
def get_success_url(self): def get_success_url(self):
return reverse('dashboard:customer-detail', kwargs={'pk': self.object.pk}) 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', '') FACEBOOK_PIXEL_ID = os.environ.get('FACEBOOK_PIXEL_ID', '')
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', '') 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_CLIENT_ID = os.environ.get('PAYPAL_CLIENT_ID', '')
PAYPAL_SECRET_ID = os.environ.get('PAYPAL_SECRET_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,89 @@
class Subscription { class SubscriptionForm {
static TWELVE_OZ form
static SIXTEEN_OZ stripe_price_input
static FIVE_LBS total_quantity_input
products_and_quantities
weight_per_item = '0'
weight_unit = 'lb'
products
max_quantity = 20
static TWELVE_SHIPPING constructor(form) {
static SIXTEEN_SHIPPING this.form = document.querySelector(form)
static FIVE_SHIPPING 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]')
this.submitBtn = this.form.querySelector('input[type=submit]')
constructor(element, output) { this.connect()
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.element = element connect() {
this.output = this.element.querySelector('.output') this.form.addEventListener('change', this.change.bind(this))
this.shippingDiscount = 10 const formData = new FormData(this.form)
this.price = this.element.querySelector('select[name=size]') }
this.products = this.element.querySelectorAll('input[name^=product]')
this.element.addEventListener('change', this.render.bind(this)) change(event) {
this.render() 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()
this.submitBtn.disabled = !this.isValid
}
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 isValid() {
return this.validate();
}
validate() {
const inputs = new Set();
[this.stripe_price_input,
this.total_quantity_input,
this.total_weight_input,
this.products_and_quantities].forEach(input => {
if (input.value == '') {
inputs.add(false)
}
});
if (this.total_qty < 1) {
inputs.add(false)
}
const valid = (inputs.has(false)) ? false : true;
return valid;
} }
get total_qty() { get total_qty() {
@ -30,99 +92,12 @@ class Subscription {
}, 0) }, 0)
} }
get hasFreeShipping() { get total_weight() {
switch(this.price.value) { const weight = this.total_qty * Number(this.weight_per_item)
case this.TWELVE_OZ: return `${weight}:${this.weight_unit}`
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() {
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
} }
} }
document.addEventListener('DOMContentLoaded', () => {
const subCreateFromEl = document.querySelector('.subscription-create-form') new SubscriptionForm('.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); 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 { .errorlist {
background-color: var(--red-color); background-color: var(--red-color);
color: white; color: white;
@ -756,25 +763,53 @@ article + article {
} }
.subscription-create-form { .subscription-authenticate {
display: grid; text-align: center;
grid-template-columns: 2fr 1fr; }
.subscription-create-form section {
margin-bottom: 3rem;
}
.subscription-coffee {
display: flex;
gap: 2rem; gap: 2rem;
overflow-x: scroll;
padding: 1rem;
border-right: var(--default-border);
border-left: var(--default-border);
} }
.product__subscription-list { .subscription-coffee div {
display: grid; text-align: center;
grid-template-columns: repeat(2, 1fr); }
.subscription-coffee img {
max-height: 300px;
}
.subscription-products {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
justify-items: center; justify-items: center;
gap: 6rem;
overflow-y: scroll;
max-height: 50rem;
border-bottom: var(--default-border);
padding: 0 2rem 2rem;
} }
.product__subscription-list div { .subscription-products > div {
/*max-width: 10rem;*/ 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, ShippingService,
ShippingProvider, ShippingProvider,
ShippingContainer, ShippingContainer,
CoffeeGrind, CoffeeGrind
build_usps_rate_request
) )
from core.usps import build_usps_rate_request
from .forms import CartItemUpdateForm from .forms import CartItemUpdateForm
from .payments import CreateOrder from .payments import CreateOrder
@ -134,10 +133,6 @@ class Cart:
} }
def deserialize(self, data): def deserialize(self, data):
# Transform old cart
if type(data) is list:
return
try: try:
self.coupon = Coupon.objects.get(code=data.get('coupon_code')) self.coupon = Coupon.objects.get(code=data.get('coupon_code'))
except Coupon.DoesNotExist: except Coupon.DoesNotExist:
@ -220,16 +215,19 @@ class Cart:
if item.variant.product in self.coupon.products.all(): if item.variant.product in self.coupon.products.all():
yield item.total_price 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 = Q(
is_selectable=True is_selectable=True
) )
min_weight_matched = Q( min_weight_matched = Q(
min_order_weight__lte=self.total_weight) | Q( min_order_weight__lte=total_weight) | Q(
min_order_weight__isnull=True min_order_weight__isnull=True
) )
max_weight_matched = Q( max_weight_matched = Q(
max_order_weight__gte=self.total_weight) | Q( max_order_weight__gte=total_weight) | Q(
max_order_weight__isnull=True max_order_weight__isnull=True
) )
containers = ShippingRate.objects.filter( 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 localflavor.us.us_states import USPS_CHOICES
from usps import USPSApi, Address from usps import USPSApi, Address
from captcha.fields import CaptchaField 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 from core import CoffeeGrind, ShippingContainer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,21 +40,6 @@ class CartItemUpdateForm(forms.Form):
quantity = forms.IntegerField(min_value=1, max_value=20, initial=1) 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): class AddressForm(forms.Form):
full_name = forms.CharField() full_name = forms.CharField()
email = forms.EmailField() email = forms.EmailField()
@ -134,53 +120,20 @@ class CouponApplyForm(forms.Form):
code = forms.CharField(label='Coupon code') code = forms.CharField(label='Coupon code')
class ContactForm(forms.Form): class SubscriptionForm(forms.Form):
GOOGLE = 'Google Search' GRIND_CHOICES = [
SHOP = 'The coffee shop' ('Whole Beans', 'Whole Beans'),
WOM = 'Word of mouth' ('Espresso', 'Espresso'),
PRODUCT = 'Coffee Bag' ('Cone Drip', 'Cone Drip'),
STORE = 'Store' ('Basket Drip', 'Basket Drip'),
OTHER = 'Other' ('French Press', 'French Press'),
('Stovetop Espresso (Moka Pot)', 'Stovetop Espresso (Moka Pot)'),
REFERAL_CHOICES = [ ('AeroPress', 'AeroPress'),
(GOOGLE, 'Google Search'), ('Percolator', 'Percolator'),
(SHOP, '"Better Living Through Coffee" coffee shop'), ('BLTC cafe pour over', 'BLTC cafe pour over')
(WOM, 'Friend/Relative'),
(PRODUCT, 'Our Coffee Bag'),
(STORE, 'PT Food Coop/other store'),
(OTHER, 'Other (please describe below)'),
] ]
grind = forms.ChoiceField(choices=GRIND_CHOICES, label='')
full_name = forms.CharField() products_and_quantities = forms.CharField(widget=forms.HiddenInput())
email_address = forms.EmailField() stripe_price_id = forms.CharField(widget=forms.HiddenInput())
referal = forms.ChoiceField( total_quantity = forms.IntegerField(widget=forms.HiddenInput())
label='How did you find our website?', total_weight = forms.CharField(widget=forms.HiddenInput())
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)

View File

@ -57,6 +57,29 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% if customer.subscriptions.count > 0 %}
<section>
<h3>Your subscriptions</h3>
<table>
<thead>
<tr>
<th>Subscription</th>
<th colspan="2">Status</th>
</tr>
</thead>
<tbody>
{% for subscription in subscriptions %}
<tr>
<td>#{{ subscription.metadata.subscription_pk }}</td>
<td>{{ subscription.status }}</td>
<td><a href="https://dashboard.stripe.com/test/subscriptions/{{ subscription.id }}">manage &nearr;</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section> <section>
<h3>Your orders</h3> <h3>Your orders</h3>
<table> <table>
@ -72,7 +95,7 @@
<tr> <tr>
<td>#{{order.pk}}</td> <td>#{{order.pk}}</td>
<td>{{order.created_at|date:"M j, Y"}}</td> <td>{{order.created_at|date:"M j, Y"}}</td>
<td>${{order.get_total_price_after_discount}}</td> <td>${{order.total_amount}}</td>
<td><a href="{% url 'storefront:order-detail' customer.pk order.pk %}">See details &rarr;</a></td> <td><a href="{% url 'storefront:order-detail' customer.pk order.pk %}">See details &rarr;</a></td>
</tr> </tr>
{% empty %} {% empty %}

View File

@ -8,6 +8,11 @@
<h1>Order #{{order.pk}}</h1> <h1>Order #{{order.pk}}</h1>
<h3>Placed on {{order.created_at|date:"M j, Y"}}</h3> <h3>Placed on {{order.created_at|date:"M j, Y"}}</h3>
</header> </header>
{% if order.subscription %}
<section>
<h3>Subscription: {{ order.subscription_description }}</h3><br>
</section>
{% endif %}
<section> <section>
<table> <table>
<thead> <thead>
@ -21,22 +26,41 @@
<tbody> <tbody>
{% for item in order.lines.all %} {% for item in order.lines.all %}
<tr> <tr>
{% with product=item.variant.product %} {% if item.variant %}
<td> {% with product=item.variant.product %}
{% if item.variant.image %} <td>
<img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}"> {% if item.variant.image %}
{% else %} <img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
<img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> {% else %}
{% endif %} <img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
</td> {% endif %}
<td> </td>
<strong>{{ item.variant }}</strong><br> <td>
{{item.customer_note}} <strong>{{ item.variant }}</strong><br>
</td> {{item.customer_note}}
<td>{{item.quantity}}</td> </td>
<td>${{item.unit_price}}</td> <td>{{item.quantity}}</td>
<td>${{item.get_total}}</td> <td>${{item.unit_price}}</td>
{% endwith %} <td>${{item.get_total}}</td>
{% endwith %}
{% elif item.product %}
{% with product=item.product %}
<td>
{% if item.variant.image %}
<img class="line__image" src="{{item.variant.image.image.url}}" alt="{{item.variant.image.image}}">
{% else %}
<img class="line__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
{% endif %}
</td>
<td>
<strong>{{ item.product }}</strong><br>
{{item.customer_note}}
</td>
<td>{{item.quantity}}</td>
<td>${{item.unit_price}}</td>
<td>${{item.get_total}}</td>
{% endwith %}
{% endif %}
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
@ -52,7 +76,7 @@
<table> <table>
<tr> <tr>
<td>Subtotal</td> <td>Subtotal</td>
<td>${{order.subtotal}}</td> <td>${{order.subtotal_amount}}</td>
</tr> </tr>
{% if order.coupon %} {% if order.coupon %}
<tr> <tr>

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" disabled>
</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

@ -2,6 +2,8 @@
{% load static %} {% load static %}
{% block head %} {% 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> <script src="{% static 'scripts/subscriptions.js' %}" defer></script>
{% endblock %} {% endblock %}
@ -11,20 +13,26 @@
<h4>SUBSCRIBE AND SAVE</h4> <h4>SUBSCRIBE AND SAVE</h4>
</div> </div>
<article> <article>
<section class=""> <section>
<form action="" class="subscription-create-form"> <form method="post" class="subscription-create-form">
{% csrf_token %} {% csrf_token %}
<div> <div>
<h4>Pick your coffee</h4> <h4>Pick your coffee</h4>
<div class="product__subscription-list"> <div class="product__subscription-list">
{% for product in product_list %} {% for product in product_list %}
<div> <div>
<label for="">{{ product.name }} <label>{{ product }}
<figure class="product__figure"> <figure class="product__figure">
<img class="product__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}"> <img class="product__image" src="{{product.get_first_img.image.url}}" alt="{{product.get_first_img.image}}">
</figure> </figure>
</label> </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 }}"> <input type="number" min="0" max="20" name="product_{{ product.pk }}">
</div> </div>
{% endfor %} {% endfor %}

View File

@ -5,11 +5,6 @@ urlpatterns = [
path('about/', views.AboutView.as_view(), name='about'), path('about/', views.AboutView.as_view(), name='about'),
path('fair-trade/', views.FairTradeView.as_view(), name='fair-trade'), path('fair-trade/', views.FairTradeView.as_view(), name='fair-trade'),
path('reviews/', views.ReviewListView.as_view(), name='reviews'), path('reviews/', views.ReviewListView.as_view(), name='reviews'),
path(
'subscriptions/',
views.SubscriptionCreateView.as_view(),
name='subscriptions'
),
path( path(
'categories/<int:pk>/', 'categories/<int:pk>/',
@ -104,4 +99,33 @@ urlpatterns = [
name='address-update', 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'
),
])),
] ]

View File

@ -1,13 +1,16 @@
import logging import logging
import locale
import requests import requests
import json import json
import stripe import stripe
from decimal import Decimal
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.shortcuts import render, reverse, redirect, get_object_or_404 from django.shortcuts import render, reverse, redirect, get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.core.cache import cache from django.core.cache import cache
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.http import JsonResponse, HttpResponseRedirect from django.http import JsonResponse, HttpResponseRedirect
from django.views.generic.base import View, RedirectView, TemplateView 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 ( from django.db.models import (
Exists, OuterRef, Prefetch, Subquery, Count, Sum, Avg, F, Q, Value 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.orders import OrdersCreateRequest, OrdersCaptureRequest
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment
@ -38,20 +42,23 @@ from accounts.forms import (
from core.models import ( from core.models import (
ProductCategory, Product, ProductVariant, ProductOption, ProductCategory, Product, ProductVariant, ProductOption,
Order, Transaction, OrderLine, Coupon, ShippingRate, Order, Transaction, OrderLine, Coupon, ShippingRate,
SiteSettings Subscription, SiteSettings
) )
from core.forms import ShippingRateForm from core.forms import ShippingRateForm
from core.shipping import get_shipping_cost
from core import OrderStatus, ShippingContainer from core import OrderStatus, ShippingContainer
from .forms import ( from .forms import (
AddToCartForm, CartItemUpdateForm, OrderCreateForm, AddToCartForm, CartItemUpdateForm, OrderCreateForm,
AddressForm, CouponApplyForm, ContactForm, CheckoutShippingForm, AddressForm, CouponApplyForm, CheckoutShippingForm,
SubscriptionCreateForm SubscriptionForm
) )
from .cart import CartItem, Cart from .cart import CartItem, Cart
from .payments import CaptureOrder from .payments import CaptureOrder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
locale.setlocale(locale.LC_ALL, 'en_US.UTF-8')
stripe.api_key = settings.STRIPE_API_KEY
class CartView(FormView): class CartView(FormView):
@ -365,7 +372,7 @@ class OrderCreateView(CreateView):
shipping_container = cart.get_shipping_container() shipping_container = cart.get_shipping_container()
form.instance.shipping_total = cart.get_shipping_price(shipping_container) form.instance.shipping_total = cart.get_shipping_price(shipping_container)
shipping_address = self.request.session.get('shipping_address') 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 form.instance.status = OrderStatus.DRAFT
self.object = form.save() self.object = form.save()
bulk_list = cart.build_bulk_list(self.object) bulk_list = cart.build_bulk_list(self.object)
@ -430,6 +437,10 @@ class CustomerDetailView(UserPassesTestMixin, LoginRequiredMixin, DetailView):
context['order_list'] = Order.objects.without_drafts().filter( context['order_list'] = Order.objects.without_drafts().filter(
customer=self.object customer=self.object
).prefetch_related('lines') ).prefetch_related('lines')
subscriptions = []
if self.object.stripe_id is not None:
subscriptions = stripe.Subscription.list(customer=self.object.stripe_id)['data']
context['subscriptions'] = subscriptions
return context return context
def test_func(self): def test_func(self):
@ -533,30 +544,274 @@ class ReviewListView(TemplateView):
template_name = 'storefront/reviews.html' template_name = 'storefront/reviews.html'
class SubscriptionCreateView(FormView): class SubscriptionFormView(FormView):
template_name = 'storefront/subscriptions.html' template_name = 'storefront/subscription/form.html'
form_class = SubscriptionCreateForm form_class = SubscriptionForm
success_url = reverse_lazy('storefront:payment-done') 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): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) 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( context['product_list'] = Product.objects.filter(
visible_in_listings=True visible_in_listings=True,
category__name='Coffee'
) )
return context return context
def form_valid(self, form):
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)
class CreatePayment(View):
def post(self, request, *args, **kwargs): class SubscriptionAddAddressView(CheckoutAddressView):
stripe.api_key = settings.STRIPE_API_KEY template_name = 'storefront/subscription/address.html'
intent = stripe.PaymentIntent.create( success_url = reverse_lazy('storefront:subscription-create')
amount=2000,
currency=settings.DEFAULT_CURRENCY,
automatic_payment_methods={ class SubscriptionCreateView(SuccessMessageMixin, CreateView):
'enabled': True, 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']
) )
return JsonResponse({ shipping_address = self.request.session.get('shipping_address')
'clientSecret': intent['client_secret'] 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-done'
) + '?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 SubscriptionDoneView(TemplateView):
template_name = 'storefront/subscription/done.html'
# 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 %} {% for category in category_list %}
<li><a class="nav__link" href="{% url 'storefront:category-detail' category.pk %}">{{ category }}</a></li> <li><a class="nav__link" href="{% url 'storefront:category-detail' category.pk %}">{{ category }}</a></li>
{% endfor %} {% 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: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:reviews' %}">Reviews</a></li>
<li><a class="nav__link" href="{% url 'storefront:about' %}">About</a></li> <li><a class="nav__link" href="{% url 'storefront:about' %}">About</a></li>

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