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

+ + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + {% endfor %} + +
Invoice IDCustomer NameDateTotalDetails
{{ invoice.id }}{{ invoice.user.name }}{{ invoice.human_created_timestamp }}${{ '%0.2f' % invoice.total }}Invoice Details
+{% 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

+ + + + + + + + + + + {% for item in invoice.line_items %} + + + + + + + {% endfor %} + +
ProductQuantityPriceTotal
{{ item.product.title }}{{ item.quantity }}${{ '%0.2f' % (item.price.price_in_cents / 100) }}${{ '%0.2f' % ((item.price.price_in_cents * item.quantity) / 100) }}
+{% 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}