shop sales pages

modified:   Makefile
	modified:   development.ini
	modified:   make_post_sell/models/invoice.py
	modified:   make_post_sell/models/shop.py
	modified:   make_post_sell/routes.py
	modified:   make_post_sell/static/css/common.css
	modified:   make_post_sell/templates/actions_view.j2
	new file:   make_post_sell/templates/sales.j2
	new file:   make_post_sell/templates/sales_details.j2
	modified:   make_post_sell/views/cart.py
	new file:   make_post_sell/views/sales.py
This commit is contained in:
Russell Ballestrini 2024-12-11 12:03:54 -05:00
parent 17a700799d
commit 2c78ba4317
11 changed files with 184 additions and 90 deletions

View file

@ -32,4 +32,5 @@ serve: dev
smtp: env
# In the third shell, run a "mock" SMTP server to catch log in emails:
sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25
#sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25
sudo env/bin/python -m aiosmtpd -l 127.0.0.1:8025

View file

@ -41,6 +41,8 @@ app.root_domain = blanka.foxhop.net
app.root_domain_owner_email = russell.ballestrini@gmail.com
app.root_url = http://blanka.foxhop.net:6501
#app.email.relay = localhost:8025
# TODO: these values should be moved to environment variables & we should create an
# example app.env environment file.
app.bucket.secure_uploads = plan-period-files

View file

@ -100,7 +100,7 @@ class Invoice(RBase, Base):
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.
# denormalized address of user for shipping products.
delivery_address = Column(UnicodeText, nullable=True)
# one to one.

View file

@ -107,6 +107,10 @@ class Shop(RBase, Base):
order_by="desc(Coupon.created_timestamp)",
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
invoices = relationship("Invoice", back_populates="shop", lazy="dynamic")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
shop_locations = relationship(

View file

@ -50,7 +50,6 @@ def includeme(config):
config.add_route("user_cart_public", "/u/cart/{cart_id}/public")
config.add_route("user_cart_unpublic", "/u/cart/{cart_id}/unpublic")
# cart routes.
config.add_route("cart", "/cart")
config.add_route("cart_checkout", "/cart/checkout")
@ -89,6 +88,9 @@ def includeme(config):
config.add_route("shop", "/s/{shop_id}")
config.add_route("shop_products", "/s/{shop_id}/products")
config.add_route("shop_sales", "/s/{shop_id}/sales")
config.add_route("shop_sales_details", "/s/{shop_id}/sales/{invoice_id}")
config.add_route("shop_settings", "/s/{shop_id}/settings")
config.add_route("shop_users", "/s/{shop_id}/users")

View file

@ -19,6 +19,10 @@ h1 {
margin-bottom: .4em;
}
tr, th {
text-align: left;
}
label {
display: block;
font-weight: bold;

View file

@ -25,6 +25,10 @@
<br/>
<a href="/s/{{ request.shop.id }}/users" class="product-edit-button mps-button">&#128100; &nbsp Shop Users</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/sales" class="product-edit-button mps-button">💰 &nbsp Shop Sales</a>
<br/>
<br/>
<a href="/s/{{ request.shop.id }}/settings" class="product-edit-button mps-button">&#9881; &nbsp Shop Settings</a>

View file

@ -0,0 +1,28 @@
{% extends "base.j2" %}
{% block content %}
<h1>Sales for {{ shop.name }}</h1>
<table style="width:100%;">
<thead>
<tr>
<th>Invoice ID</th>
<th>Customer Name</th>
<th>Date</th>
<th>Total</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td>{{ invoice.id }}</td>
<td>{{ invoice.user.name }}</td>
<td>{{ invoice.human_created_timestamp }}</td>
<td>${{ '%0.2f' % invoice.total }}</td>
<td><a href="{{ request.route_url('shop_sales_details', shop_id=shop.id, invoice_id=invoice.id) }}">Invoice Details</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.j2" %}
{% block content %}
<h1>Invoice: {{ invoice.id }}</h1>
<p><strong>Customer Name:</strong> {{ invoice.user.name }}</p>
<p><strong>Date:</strong> {{ invoice.human_created_timestamp }}</p>
<p><strong>Total:</strong> ${{ '%0.2f' % invoice.total }}</p>
<p><strong>Shipping/Pickup:</strong> {{ invoice.handling_option }}</p>
<p><strong>Handling Cost:</strong> ${{ '%0.2f' % ((invoice.handling_cost_in_cents or 0) / 100) }}</p>
{% if invoice.delivery_address %}
<p><strong>Delivery Address:</strong> {{ invoice.delivery_address }}</p>
{% endif %}
<h2>Line Items</h2>
<table>
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for item in invoice.line_items %}
<tr">
<td><a href="/p/{{ item.product.id }}">{{ item.product.title }}</a></td>
<td>{{ item.quantity }}</td>
<td>${{ '%0.2f' % (item.price.price_in_cents / 100) }}</td>
<td>${{ '%0.2f' % ((item.price.price_in_cents * item.quantity) / 100) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -1,5 +1,7 @@
from pyramid.view import view_config
from transaction import TransactionManager
from . import (
user_required,
get_referer_or_home,
@ -457,7 +459,6 @@ def cart_checkout(request):
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
@view_config(route_name="user_cart_complete_checkout")
@user_required()
@shop_is_ready_required()
@ -486,99 +487,82 @@ def cart_complete_checkout(request):
request.session.flash((error_message, "error"))
return HTTPFound(get_referer_or_home(request))
invoices = []
successful_checkout = True
tm = TransactionManager()
for shop_id, product_quantity_tuple in cart.shop_product_dict.items():
shop = cart.shops[shop_id]
invoice = Invoice(user=request.user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
try:
tm.begin()
invoices = []
successful_checkout = True
# Copy the user's active address to the invoice
if cart.physical_products:
invoice.delivery_address = request.user.active_address.data
for shop_id, product_quantity_tuple in cart.shop_product_dict.items():
shop = cart.shops[shop_id]
invoice = Invoice(request.user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
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,
)
if cart.physical_products:
invoice.delivery_address = request.user.active_address.data
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,
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 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
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
if successful_checkout:
# Update inventory for physical products
cart.update_inventory(request.shop_location)
request.dbsession.add(invoice)
invoices.append(invoice)
# flush changes to database because charge was successful!
request.dbsession.flush()
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
# email the authenticated user regarding their purchase.
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")
shop = invoice.shop
request.session.flash(msg)
return HTTPFound(get_referer_or_home(request))
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:
successful_checkout = False
raise
if successful_checkout:
tm.commit()
cart.update_inventory(request.shop_location)
msg = ("Success, you have completed the purchase!", "success")
request.session.flash(msg)
for invoice in invoices:
send_purchase_email(
request,
request.user.email,
[item.product for item in invoice.line_items],
invoice.total,
)
send_sale_email(
request,
invoice.shop,
[item.product for item in invoice.line_items],
invoice.total,
)
save_cart(request)
return HTTPFound("/u/purchases")
else:
tm.abort()
msg = ("Payment failed. Please check your card details.", "error")
request.session.flash(msg)
return HTTPFound("/billing")
except Exception as e:
request.session.flash(("Payment failed: Please check your card details. {}".format(str(e)), "error"))
return HTTPFound("/billing")

View file

@ -0,0 +1,30 @@
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPForbidden
from ..models.invoice import get_invoice_by_id
from ..models.user import get_user_by_id
from ..models.shop import get_shop_by_id
from ..models.invoice import Invoice
from . import shop_editor_required
from sqlalchemy import desc
@view_config(route_name="shop_sales", renderer="sales.j2")
@shop_editor_required()
def shop_sales(request):
shop = request.shop
invoices = shop.invoices.order_by(desc(Invoice.created_timestamp)).all()
return {"invoices": invoices, "shop": shop}
@view_config(route_name="shop_sales_details", renderer="sales_details.j2")
@shop_editor_required()
def shop_sales_details(request):
shop_id = request.matchdict["shop_id"]
invoice_id = request.matchdict["invoice_id"]
shop = get_shop_by_id(request.dbsession, shop_id)
invoice = get_invoice_by_id(request.dbsession, invoice_id)
if not invoice or invoice.shop != shop:
raise HTTPForbidden("Access denied.")
return {"invoice": invoice}