Merge branch 'feature/coffee-subscriptions' into develop
This commit is contained in:
commit
510dcd1462
894
Pipfile.lock
generated
894
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
34
bens_account.json
Normal file
34
bens_account.json
Normal 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]
|
||||||
|
}
|
||||||
|
}]
|
||||||
@ -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
|
||||||
# )
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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'],
|
||||||
|
|||||||
@ -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'
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
18
src/core/migrations/0016_alter_productvariant_stripe_id.py
Normal file
18
src/core/migrations/0016_alter_productvariant_stripe_id.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
src/core/migrations/0017_stripeprice.py
Normal file
27
src/core/migrations/0017_stripeprice.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
21
src/core/migrations/0023_subscription_total_weight.py
Normal file
21
src/core/migrations/0023_subscription_total_weight.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
src/core/migrations/0024_subscription_shipping_address.py
Normal file
20
src/core/migrations/0024_subscription_shipping_address.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/core/migrations/0026_orderline_product.py
Normal file
19
src/core/migrations/0026_orderline_product.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/core/migrations/0027_order_subscription.py
Normal file
19
src/core/migrations/0027_order_subscription.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/core/migrations/0028_order_subscription_description.py
Normal file
18
src/core/migrations/0028_order_subscription_description.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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
84
src/core/shipping.py
Normal 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
|
||||||
@ -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(
|
||||||
|
|||||||
74
src/core/subscription_utils.py
Normal file
74
src/core/subscription_utils.py
Normal 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()
|
||||||
@ -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'
|
||||||
|
}
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
45
src/dashboard/templates/dashboard/subscription/list.html
Normal file
45
src/dashboard/templates/dashboard/subscription/list.html
Normal 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">« 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 »</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
{% endblock content %}
|
||||||
@ -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 %}">
|
||||||
|
|||||||
@ -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'
|
||||||
|
# ),
|
||||||
|
# ])),
|
||||||
|
])),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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', '')
|
||||||
|
|||||||
BIN
src/static/images/recurrent.png
Normal file
BIN
src/static/images/recurrent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@ -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)
|
})
|
||||||
|
|||||||
154
src/static/scripts/subscriptions_old.js
Normal file
154
src/static/scripts/subscriptions_old.js
Normal 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'))
|
||||||
|
})
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|||||||
@ -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 ↗</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 →</a></td>
|
<td><a href="{% url 'storefront:order-detail' customer.pk order.pk %}">See details →</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
@ -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 }} × {{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 %}
|
||||||
18
src/storefront/templates/storefront/subscription/done.html
Normal file
18
src/storefront/templates/storefront/subscription/done.html
Normal 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 %}
|
||||||
62
src/storefront/templates/storefront/subscription/form.html
Normal file
62
src/storefront/templates/storefront/subscription/form.html
Normal 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 %}
|
||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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'
|
||||||
|
),
|
||||||
|
])),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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'})
|
||||||
|
|||||||
@ -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
225
subscription_object.py
Normal 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,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user