diff --git a/Makefile b/Makefile
index 500937f..111abb8 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/development.ini b/development.ini
index cb71a5d..e209f09 100644
--- a/development.ini
+++ b/development.ini
@@ -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
diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py
index 46b5957..0907960 100644
--- a/make_post_sell/models/invoice.py
+++ b/make_post_sell/models/invoice.py
@@ -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.
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index 1503c19..30f36f1 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -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(
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 6407a89..5fd1d95 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -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")
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 11a06b0..56e5f13 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -19,6 +19,10 @@ h1 {
margin-bottom: .4em;
}
+tr, th {
+ text-align: left;
+}
+
label {
display: block;
font-weight: bold;
diff --git a/make_post_sell/templates/actions_view.j2 b/make_post_sell/templates/actions_view.j2
index 697d37e..ede4a82 100644
--- a/make_post_sell/templates/actions_view.j2
+++ b/make_post_sell/templates/actions_view.j2
@@ -25,6 +25,10 @@
👤   Shop Users
+
+
+ 💰   Shop Sales
+
⚙   Shop Settings
diff --git a/make_post_sell/templates/sales.j2 b/make_post_sell/templates/sales.j2
new file mode 100644
index 0000000..6fdfcda
--- /dev/null
+++ b/make_post_sell/templates/sales.j2
@@ -0,0 +1,28 @@
+{% extends "base.j2" %}
+
+{% block content %}
+
Sales for {{ shop.name }}
+
+
+
+
+ | Invoice ID |
+ Customer Name |
+ Date |
+ Total |
+ Details |
+
+
+
+ {% for invoice in invoices %}
+
+ | {{ invoice.id }} |
+ {{ invoice.user.name }} |
+ {{ invoice.human_created_timestamp }} |
+ ${{ '%0.2f' % invoice.total }} |
+ Invoice Details |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/make_post_sell/templates/sales_details.j2 b/make_post_sell/templates/sales_details.j2
new file mode 100644
index 0000000..5898b52
--- /dev/null
+++ b/make_post_sell/templates/sales_details.j2
@@ -0,0 +1,35 @@
+{% extends "base.j2" %}
+
+{% block content %}
+ Invoice: {{ invoice.id }}
+ Customer Name: {{ invoice.user.name }}
+ Date: {{ invoice.human_created_timestamp }}
+ Total: ${{ '%0.2f' % invoice.total }}
+ Shipping/Pickup: {{ invoice.handling_option }}
+ Handling Cost: ${{ '%0.2f' % ((invoice.handling_cost_in_cents or 0) / 100) }}
+ {% if invoice.delivery_address %}
+ Delivery Address: {{ invoice.delivery_address }}
+ {% endif %}
+
+ Line Items
+
+
+
+ | Product |
+ Quantity |
+ Price |
+ Total |
+
+
+
+ {% for item in invoice.line_items %}
+
+ | {{ item.product.title }} |
+ {{ item.quantity }} |
+ ${{ '%0.2f' % (item.price.price_in_cents / 100) }} |
+ ${{ '%0.2f' % ((item.price.price_in_cents * item.quantity) / 100) }} |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py
index 289f160..b2f2725 100644
--- a/make_post_sell/views/cart.py
+++ b/make_post_sell/views/cart.py
@@ -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")
diff --git a/make_post_sell/views/sales.py b/make_post_sell/views/sales.py
new file mode 100644
index 0000000..7d50d9c
--- /dev/null
+++ b/make_post_sell/views/sales.py
@@ -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}