Handling selection and cost now seem to work.

only thing next is blocking if there is a need for handling options and
user needs address.

	modified:   make_post_sell/models/cart.py
	modified:   make_post_sell/models/inventory.py
	modified:   make_post_sell/models/invoice.py
	modified:   make_post_sell/routes.py
	new file:   make_post_sell/scripts/alembic/versions/1b3ecdde9e65_handling_options.py
	modified:   make_post_sell/templates/cart.j2
	modified:   make_post_sell/views/cart.py
This commit is contained in:
Russell Ballestrini 2024-06-16 14:33:21 -04:00
parent e5c49e5347
commit 60b495b36a
7 changed files with 394 additions and 129 deletions

View file

@ -6,22 +6,16 @@ from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy import (
Column,
Boolean,
BigInteger,
Unicode,
UnicodeText
)
from sqlalchemy import Column, Boolean, BigInteger, Unicode, UnicodeText
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
bucket_objects_by_id,
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
bucket_objects_by_id,
)
from .product import get_products_by_ids
@ -29,6 +23,8 @@ from .shop import get_shops_by_ids
from .cart_coupon import CartCoupon
from .inventory import get_inventory_by_product_and_shop_location
from ..lib.currency import cents_to_dollars
try:
@ -40,16 +36,20 @@ except:
def timestamp_to_datetime(timestamp):
"""Accepts a milliseconds timestamp integer and returns a datetime"""
from datetime import datetime
return datetime.fromtimestamp(timestamp / 1000.0)
def timestamp_to_ago_string(timestamp):
"""Accepts a timestamp and returns a human readable string"""
from ago import human
return human(timestamp_to_datetime(timestamp), 2, abbreviate=True)
class Cart(RBase, Base):
"""This class tracks a User's shopping carts."""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=True)
@ -57,13 +57,18 @@ class Cart(RBase, Base):
public = Column(Boolean, default=False)
json_cart = Column(UnicodeText, default=unicode("{}"))
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
active = Column(Boolean, default=False)
# many to many uses association_proxy.
coupons = association_proxy("cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c))
coupons = association_proxy(
"cart_coupons", "coupon", creator=lambda c: CartCoupon(coupon=c)
)
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
@ -85,6 +90,8 @@ class Cart(RBase, Base):
del self._line_totals_in_cents
if hasattr(self, "_products"):
del self._products
if hasattr(self, "_physical_products"):
del self._physical_products
if hasattr(self, "_shops"):
del self._shops
if hasattr(self, "_shop_product_dict"):
@ -122,13 +129,13 @@ class Cart(RBase, Base):
def add_product(self, product):
quantity = 1
if str(product.id) in self.cart:
quantity = self.cart[str(product.id)] + 1
if product.uuid_str in self.cart:
quantity = self.cart[product.uuid_str] + 1
self.set_product_quantity(product, quantity)
def remove_product(self, product):
tmp_cart = self.cart
del tmp_cart[str(product.id)]
del tmp_cart[product.uuid_str]
self.cart = tmp_cart
def set_product_quantity(self, product, quantity):
@ -136,7 +143,7 @@ class Cart(RBase, Base):
if quantity >= 999:
quantity = 999
tmp_cart = self.cart
tmp_cart[str(product.id)] = quantity
tmp_cart[product.uuid_str] = quantity
self.cart = tmp_cart
def get_product_quantity(self, product):
@ -161,7 +168,7 @@ class Cart(RBase, Base):
def products(self):
"""
Return a dictionary of unique Product objects in cart.
The key is the product UUID, and the value is the Product object.
"""
if hasattr(self, "_products") == False:
@ -170,6 +177,20 @@ class Cart(RBase, Base):
self._products = bucket_objects_by_id(products)
return self._products
@property
def physical_products(self):
"""
Return a dictionary of physical Product objects in cart.
The key is the product UUID, and the value is the Product object.
"""
if not hasattr(self, "_physical_products"):
self._physical_products = {}
for product_id, product in self.products.items():
if product.is_physical:
self._physical_products[product_id] = product
return self._physical_products
@property
def shops(self):
"""
@ -178,7 +199,6 @@ class Cart(RBase, Base):
The key is the Shop UUID, and the value is the Shop object.
"""
if hasattr(self, "_shops") == False:
self._shops = {}
# loop over Products in cart, aggregate a unique set of shop_ids.
@ -195,18 +215,17 @@ class Cart(RBase, Base):
@property
def shop_product_dict(self):
"""
Return a dict where keys are shop_ids and values are a list of product_ids.
Return a dict where keys are shop_ids (as strings) and values are a list of tuples (product, quantity).
"""
if hasattr(self, "_shop_product_dict") == False:
if not hasattr(self, "_shop_product_dict"):
self._shop_product_dict = {}
for shop_id in self.shops.keys():
self._shop_product_dict[shop_id] = []
for product in self.products.values():
shop_id = product.id_to_uuid_str(product.shop_id)
self._shop_product_dict[shop_id].append(product.uuid_str)
for product_id, quantity in self.cart.items():
product = self.products[product_id]
shop_id = product.shop.uuid_str
if shop_id not in self._shop_product_dict:
self._shop_product_dict[shop_id] = []
self._shop_product_dict[shop_id].append((product, quantity))
return self._shop_product_dict
@ -231,12 +250,14 @@ class Cart(RBase, Base):
@property
def shop_totals_in_cents(self):
if hasattr(self, "_shop_totals_in_cents") == False:
if not hasattr(self, "_shop_totals_in_cents"):
self._shop_totals_in_cents = {}
for shop_id, product_ids in self.shop_product_dict.items():
for shop_id, product_quantity_tuple in self.shop_product_dict.items():
self._shop_totals_in_cents[shop_id] = 0
for product_id in product_ids:
self._shop_totals_in_cents[shop_id] += self.line_totals_in_cents[product_id]
for product, _ in product_quantity_tuple:
self._shop_totals_in_cents[shop_id] += self.line_totals_in_cents[
product.uuid_str
]
return self._shop_totals_in_cents
@property
@ -252,38 +273,66 @@ class Cart(RBase, Base):
"""Discounted shop totals in cents after applying coupons."""
if hasattr(self, "_discounted_shop_totals_in_cents") == False:
from copy import deepcopy
self._discounted_shop_totals_in_cents = deepcopy(self.shop_totals_in_cents)
if len(self.coupons) > 0:
for coupon in sorted(self.coupons):
for shop_uuid, shop_total_in_cents in self.shop_totals_in_cents.items():
for (
shop_uuid,
shop_total_in_cents,
) in self.shop_totals_in_cents.items():
if coupon.shop_uuid_str == shop_uuid:
self._discounted_shop_totals_in_cents[shop_uuid] = coupon.compute_discount(shop_total_in_cents)
self._discounted_shop_totals_in_cents[
shop_uuid
] = coupon.compute_discount(shop_total_in_cents)
return self._discounted_shop_totals_in_cents
@property
def discounted_shop_totals(self):
if hasattr(self, "_discounted_shop_totals") == False:
self._discounted_shop_totals = {}
for shop_id, discounted_shop_total_in_cents in self.discounted_shop_totals_in_cents.items():
self._discounted_shop_totals[shop_id] = cents_to_dollars(discounted_shop_total_in_cents)
for (
shop_id,
discounted_shop_total_in_cents,
) in self.discounted_shop_totals_in_cents.items():
self._discounted_shop_totals[shop_id] = cents_to_dollars(
discounted_shop_total_in_cents
)
return self._discounted_shop_totals
@property
def total_price_in_cents(self):
# TODO: memoize?
return sum(self.line_totals_in_cents.values())
"""
Calculate the total price in cents, including handling cost if applicable.
"""
total = sum(self.line_totals_in_cents.values())
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@property
def total_price(self):
"""
Total price in dollars, including handling cost if applicable.
"""
return cents_to_dollars(self.total_price_in_cents)
@property
def total_discounted_price_in_cents(self):
# TODO: memoize?
return sum(self.discounted_shop_totals_in_cents.values())
"""
Calculate the total discounted price in cents, including handling cost if applicable.
"""
total = sum(self.discounted_shop_totals_in_cents.values())
if self.handling_cost_in_cents:
total += self.handling_cost_in_cents
return total
@property
def total_discounted_price(self):
"""
Total discounted price in dollars, including handling cost if applicable.
"""
return cents_to_dollars(self.total_discounted_price_in_cents)
@property
@ -294,14 +343,18 @@ class Cart(RBase, Base):
@property
def total(self):
"""The total (after discounts) to be charged to user via an Invoice."""
"""
The total in dollars (after discounts) to be charged to user via an Invoice, including handling cost if applicable.
"""
if self.is_discounted:
return self.total_discounted_price
return self.total_price
@property
def total_in_cents(self):
"""The total in cents (after discounts)."""
"""
The total in cents (after discounts) to be charged to user via an Invoice, including handling cost if applicable.
"""
if self.is_discounted:
return self.total_discounted_price_in_cents
return self.total_price_in_cents
@ -339,25 +392,65 @@ class Cart(RBase, Base):
for coupon in self.coupons:
# test if the shop's cart total qualifies for this coupon.
if coupon.is_not_valid:
error_messages.append("Sorry that coupon is not valid (expired or disabled).")
elif self.shop_totals_in_cents[coupon.shop_uuid_str] < coupon.cart_qualifier:
error_messages.append("Please review coupon terms: shop total not met.")
elif coupon.max_redemptions and coupon.redemptions.count() >= coupon.max_redemptions:
error_messages.append("That coupon was used too many times. Please remove it.")
elif coupon.max_redemptions_per_user and coupon.redemptions.filter_by(user_id=self.user_id).count() >= coupon.max_redemptions_per_user:
error_messages.append("You have already used this coupon. Please remove it.")
error_messages.append(
"Sorry that coupon is not valid (expired or disabled)."
)
elif (
self.shop_totals_in_cents[coupon.shop_uuid_str]
< coupon.cart_qualifier
):
error_messages.append(
"Please review coupon terms: shop total not met."
)
elif (
coupon.max_redemptions
and coupon.redemptions.count() >= coupon.max_redemptions
):
error_messages.append(
"That coupon was used too many times. Please remove it."
)
elif (
coupon.max_redemptions_per_user
and coupon.redemptions.filter_by(user_id=self.user_id).count()
>= coupon.max_redemptions_per_user
):
error_messages.append(
"You have already used this coupon. Please remove it."
)
return error_messages
def check_inventory(self, shop_location):
"""
Check if the shop location has enough quantity for each physical product in the cart.
Returns a list of error messages if any product is out of stock.
"""
error_messages = []
for product_id, product in self.physical_products.items():
quantity_needed = self.cart[product_id]
inventory = get_inventory_by_product_and_shop_location(
self.dbsession, product_id, shop_location.uuid_str
)
if inventory is None or inventory.quantity < quantity_needed:
error_messages.append(
f"Not enough stock for {product.title}. Switch Shop Location and try again. Needed: {quantity_needed}, Available: {inventory.quantity if inventory else 0}"
)
return error_messages
def remove_handling_if_no_physical_products(self):
"""
Remove the handling option and cost if there are no physical products in the cart.
"""
if not self.physical_products:
self.handling_option = None
self.handling_cost_in_cents = 0
def get_all_carts(dbsession):
"""
Return all Cart objects in descending order by
created timestamp (newest first).
"""
return (
dbsession.query(Cart)
.order_by(Cart.created_timestamp.desc())
)
return dbsession.query(Cart).order_by(Cart.created_timestamp.desc())
def get_cart_by_id(dbsession, cart_id):

View file

@ -3,11 +3,14 @@ from sqlalchemy import Column, Integer, BigInteger
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
from sqlalchemy.orm import relationship
class Inventory(RBase, Base):
"""This class represents the inventory of a product at a specific location."""
id = Column(UUIDType, primary_key=True, index=True)
shop_location_id = Column(UUIDType, foreign_key("ShopLocation", "id"), nullable=False)
shop_location_id = Column(
UUIDType, foreign_key("ShopLocation", "id"), nullable=False
)
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
quantity = Column(Integer, nullable=False, default=0)
created_timestamp = Column(BigInteger, nullable=False)
@ -29,3 +32,19 @@ class Inventory(RBase, Base):
def update_quantity(self, quantity):
self.quantity = quantity
self.updated_timestamp = now_timestamp()
def get_inventory_by_product_and_shop_location(dbsession, product_id, shop_location_id):
"""
Query the Inventory table by product_id and shop_location_id.
:param dbsession: The SQLAlchemy session object.
:param product_id: The UUID of the product.
:param shop_location_id: The UUID of the shop location.
:return: The Inventory object if found, else None.
"""
return (
dbsession.query(Inventory)
.filter_by(product_id=product_id, shop_location_id=shop_location_id)
.first()
)

View file

@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Integer, UnicodeText
from sqlalchemy import Column, BigInteger, Boolean, Integer, UnicodeText, Unicode
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
@ -8,6 +8,8 @@ from sqlalchemy.orm import relationship, backref
from .coupon_redemption import CouponRedemption
from ..lib.currency import cents_to_dollars
class InvoiceLineItem(RBase, Base):
"""
@ -15,6 +17,7 @@ class InvoiceLineItem(RBase, Base):
We should do our best to treat these line items as immutable.
This will let us rely on them as a proof of transactional record.
"""
id = Column(UUIDType, primary_key=True, index=True)
# a way to group multiple transactions into a single reference.
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=False)
@ -57,9 +60,8 @@ class InvoiceLineItem(RBase, Base):
shop=None,
market=None,
quantity=1,
credit=False
credit=False,
):
if price is None:
price = product.current_price
if shop is None:
@ -81,6 +83,7 @@ class Invoice(RBase, Base):
A user may have zero or many invoices
which relate to a purchase transaction.
"""
id = Column(UUIDType, primary_key=True, index=True)
# the user making the purchase.
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
@ -93,6 +96,10 @@ class Invoice(RBase, Base):
# the timestamp when the invoice was created.
created_timestamp = Column(BigInteger, nullable=False)
# optional physical product handling.
handling_option = Column(Unicode(64), nullable=True)
handling_cost_in_cents = Column(BigInteger, nullable=True, default=0)
# the user's address to ship the item to.
delivery_address = Column(UnicodeText, nullable=True)
@ -109,18 +116,20 @@ class Invoice(RBase, Base):
# returns all the InvoiceLineItems.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
line_items = relationship(argument="InvoiceLineItem", lazy="dynamic", back_populates="invoice")
line_items = relationship(
argument="InvoiceLineItem", lazy="dynamic", back_populates="invoice"
)
# one to many.
# returns all the CouponRedemptions for this Invoice.
# lazy="dynamic" returns a query object instead of an InstrumentedList.
# this is ideal when we want to further filter the results.
coupon_redemptions = relationship(argument="CouponRedemption", lazy="dynamic", back_populates="invoice")
# TODO: We need a method that returns InvoiceLineItem grouped by shop.
# we will need this when we start supporting market places.
#line_items_grouped_by_shop
coupon_redemptions = relationship(
argument="CouponRedemption", lazy="dynamic", back_populates="invoice"
)
# maybe not needed anymore, an invoice is only good for one shop.
# so multiple shops in a cart will mean multiple invoices.
# TODO: We need a way to relate none or many CouponRedemption
# to an Invoice. This will allow us to keep track of where the
# coupons are utilized and how they affect the invoice total.
@ -186,5 +195,28 @@ class Invoice(RBase, Base):
invoice=self,
shop=coupon.shop,
user=self.user,
)
)
)
@property
def total_in_cents(self):
"""Calculate the total amount in cents for the invoice, including handling fee."""
line_items_total = sum(
item.price_in_cents * item.quantity for item in self.line_items
)
return line_items_total + (self.handling_cost_in_cents or 0)
@property
def total(self):
"""Total in dollars."""
return cents_to_dollars(self.total_in_cents)
@property
def requires_payment(self):
"""Return True if we should charge a card, else False."""
# Only charge a credit card if invoice total is greater than $0.64!
# Credit card "gas" payment costs $0.30 + 2.9%
# We gift the customer up to $0.32 when we bypass credit card gas.
if self.total_in_cents > 64:
return True
return False

View file

@ -59,6 +59,8 @@ def includeme(config):
config.add_route("cart_remove_product", "/cart/{cart_id}/remove")
config.add_route("cart_quantity_product", "/cart/{cart_id}/quantity")
config.add_route("cart_handling_option", "/u/cart/{cart_id}/handling-option")
# coupon routes.
config.add_route("coupon_new1", "/coupon/new")
config.add_route("coupon_new2", "/s/{shop_id}/coupon/new")
@ -74,7 +76,9 @@ def includeme(config):
config.add_route("shop_location_new", "/s/{shop_id}/locations/new")
config.add_route("shop_location_edit", "/s/{shop_id}/locations/{location_id}/edit")
config.add_route("shop_locations", "/s/{shop_id}/locations")
config.add_route('shop_location_switch', '/s/{shop_id}/location/{location_id}/switch')
config.add_route(
"shop_location_switch", "/s/{shop_id}/location/{location_id}/switch"
)
# shop routes.
config.add_route("shop_new", "/s/new")
@ -109,7 +113,7 @@ def includeme(config):
config.add_route("product_new", "/p/new")
config.add_route("product", "/p/{product_id}")
config.add_route("product_description_edit", "/p/{product_id}/description/edit")
config.add_route('product_inventory_edit', '/p/{product_id}/inventory/edit')
config.add_route("product_inventory_edit", "/p/{product_id}/inventory/edit")
config.add_route("product_bundle_edit", "/p/{product_id}/bundle/edit")
config.add_route("product_bundle_edit2", "/p/{product_id}/{slug:.*}/bundle/edit")
config.add_route(

View file

@ -0,0 +1,36 @@
"""handling options
Revision ID: 1b3ecdde9e65
Revises: ddfbd6d96915
Create Date: 2024-06-16 14:30:57.649239
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b3ecdde9e65'
down_revision = 'ddfbd6d96915'
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mps_cart', sa.Column('handling_option', sa.Unicode(length=64), nullable=True))
op.add_column('mps_cart', sa.Column('handling_cost_in_cents', sa.BigInteger(), nullable=True))
op.add_column('mps_invoice', sa.Column('handling_option', sa.Unicode(length=64), nullable=True))
op.add_column('mps_invoice', sa.Column('handling_cost_in_cents', sa.BigInteger(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mps_invoice', 'handling_cost_in_cents')
op.drop_column('mps_invoice', 'handling_option')
op.drop_column('mps_cart', 'handling_cost_in_cents')
op.drop_column('mps_cart', 'handling_option')
# ### end Alembic commands ###

View file

@ -80,7 +80,7 @@
{% else %}
{% for shop_id, product_ids in shop_product_dict.items() %}
{% for shop_id, product_quantity_tuples in shop_product_dict.items() %}
{% set shop = shops[shop_id] %}
@ -90,11 +90,10 @@
<b style="font-size: 1.5em;">{{ shop.name }}</b>
</div>
{% for product_id in product_ids %}
{% for product, quantity in product_quantity_tuples %}
{% set product = products[product_id] %}
{% set product_quantity = cart.cart[product_id] %}
{% set line_total = cart.line_totals[product_id] %}
{% set product_quantity = quantity %}
{% set line_total = cart.line_totals[product.uuid_str] %}
<div class="cart-item">
{% if "thumbnail1" in product.extensions %}
@ -131,6 +130,31 @@
{% endfor %}
<!-- Handling Options -->
{% if cart.physical_products %}
<h3>Physical Handling Options</h3>
<form action="{{ request.route_url('cart_handling_option', cart_id=cart.id) }}" method="POST">
{% if request.shop_location.local_pickup %}
<input type="radio" id="local_pickup" name="handling_option" value="local_pickup" required>
<label for="local_pickup" style="display: inline;">Local Pickup (Free)</label><br>
{% endif %}
{% if request.shop_location.local_delivery %}
<input type="radio" id="local_delivery" name="handling_option" value="local_delivery" required>
<label for="local_delivery" style="display: inline;">Local Delivery (${{ '%0.2f' % request.shop_location.local_delivery_rate }})</label><br>
{% endif %}
{% if request.shop_location.local_shipping %}
<input type="radio" id="local_shipping" name="handling_option" value="local_shipping" required>
<label for="local_shipping" style="display: inline;">Local Shipping (${{ '%0.2f' % request.shop_location.local_shipping_rate }})</label><br>
{% endif %}
{% if request.shop_location.international_shipping %}
<input type="radio" id="international_shipping" name="handling_option" value="international_shipping" required>
<label for="international_shipping" style="display: inline;">International Shipping (${{ '%0.2f' % request.shop_location.international_shipping_rate }})</label><br>
{% endif %}
<input type="submit" value="Set Handling Option">
</form>
{% endif %}
<div style="text-align: right; grid-column: span 3">
{% if cart.is_discounted %}
@ -148,7 +172,6 @@
{% endfor %}
<br/>
<br/>

View file

@ -267,6 +267,7 @@ def cart_remove_product(request):
):
msg = ('You removed "{}" from the cart.'.format(product.title), "success")
cart.remove_product(product)
cart.remove_handling_if_no_physical_products()
request.dbsession.add(cart)
request.dbsession.flush()
@ -336,6 +337,41 @@ def cart_quantity_product(request):
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="cart_handling_option", request_method="POST")
@user_required()
def cart_handling_option(request):
cart = get_cart_from_matchdict(request)
handling_option = request.params.get("handling_option")
if handling_option not in [
"local_pickup",
"local_delivery",
"local_shipping",
"international_shipping",
]:
request.session.flash(("Invalid handling option selected.", "error"))
return HTTPFound(get_referer_or_home(request))
cart.handling_option = handling_option
if handling_option == "local_pickup":
cart.handling_cost_in_cents = 0
elif handling_option == "local_delivery":
cart.handling_cost_in_cents = request.shop_location.local_delivery_rate_in_cents
elif handling_option == "local_shipping":
cart.handling_cost_in_cents = request.shop_location.local_shipping_rate_in_cents
elif handling_option == "international_shipping":
cart.handling_cost_in_cents = (
request.shop_location.international_shipping_rate_in_cents
)
request.dbsession.add(cart)
request.dbsession.flush()
request.session.flash(("Handling option set successfully.", "success"))
return HTTPFound("/u/cart/{}".format(cart.id))
@view_config(route_name="cart_checkout", renderer="cart_checkout.j2")
@view_config(route_name="user_cart_checkout", renderer="cart_checkout.j2")
@user_required(
@ -379,6 +415,13 @@ def cart_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# check inventory for physical products.
inventory_errors = cart.check_inventory(request.shop_location)
if inventory_errors:
for error_message in inventory_errors:
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
msg = ("Please confirm your order.", "info")
request.session.flash(msg)
return {
@ -419,61 +462,67 @@ def cart_complete_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
# TODO: currently this routine is naive to "market" invoices, an Invoice
# which has InvoiceLineItems for Products derived from more than one Shop.
invoice = Invoice(user=request.user)
invoice.shop = request.shop
invoice.market = request.market
for product_id, quantity in cart.cart.items():
product = cart.products[product_id]
product.unlock_for_user(request.user)
request.dbsession.add(product)
invoice.new_line_item(
product=product,
quantity=quantity,
market=invoice.market,
)
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
request.dbsession.add(invoice)
invoices = []
successful_checkout = True
# only create a real charge if the cart has a positive price.
# this conditional allows us to support freebies.
if cart.requires_payment:
stripe_user_shop = request.shop.stripe_user_shop(request.user)
for shop_id, product_quantity_tuple in cart.shop_product_dict.items():
shop = cart.shops[shop_id]
invoice = Invoice(user=request.user)
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
try:
request.shop.stripe.PaymentIntent.create(
amount=cart.total_in_cents,
currency="usd",
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
for product, quantity in product_quantity_tuple:
product.unlock_for_user(request.user)
request.dbsession.add(product)
invoice.new_line_item(
product=product,
quantity=quantity,
)
except request.shop.stripe.error.CardError as e:
# rollback changes to database because charge was NOT successful!
request.dbsession.rollback()
err = e.error
# Error code will be authentication_required if authentication is needed
payment_intent_id = err.payment_intent["id"]
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
request.flash(
"Payment Error: {} {} - {}".format(
payment_intent.last_payment_error.code,
payment_intent.last_payment_error.decline_code,
payment_intent.last_payment_error.message,
),
"error",
)
successful_checkout = False
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
request.dbsession.add(invoice)
invoices.append(invoice)
# Loop over each invoice to handle payments
for invoice in invoices:
shop = invoice.shop
# only create a real charge if the invoice has a positive price.
if invoice.requires_payment:
stripe_user_shop = shop.stripe_user_shop(request.user)
try:
shop.stripe.PaymentIntent.create(
amount=invoice.total_in_cents,
currency="usd",
customer=stripe_user_shop.cus_id,
payment_method=stripe_user_shop.active_card_id,
off_session=True,
confirm=True,
)
except shop.stripe.error.CardError as e:
# rollback changes to database because charge was NOT successful!
request.dbsession.rollback()
err = e.error
# Error code will be authentication_required if authentication is needed
payment_intent_id = err.payment_intent["id"]
payment_intent = shop.stripe.PaymentIntent.retrieve(
payment_intent_id
)
request.flash(
"Payment Error: {} {} - {}".format(
payment_intent.last_payment_error.code,
payment_intent.last_payment_error.decline_code,
payment_intent.last_payment_error.message,
),
"error",
)
successful_checkout = False
break
if successful_checkout:
# flush changes to database because charge was successful!
@ -481,11 +530,20 @@ def cart_complete_checkout(request):
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
# email the authenticated user regarding their purchase.
send_purchase_email(
request, request.user.email, cart.products.values(), cart.total
)
# email the shop owners regarding their sale.
send_sale_email(request, request.shop, cart.products.values(), cart.total)
for invoice in invoices:
send_purchase_email(
request,
request.user.email,
[item.product for item in invoice.line_items],
invoice.total,
)
# email the shop owners regarding their sale.
send_sale_email(
request,
invoice.shop,
[item.product for item in invoice.line_items],
invoice.total,
)
# save the cart, so that we end up with a new empty cart.
save_cart(request)
return HTTPFound("/u/purchases")