From 098f008dfd669c01b116e8fcdad684cc78786c0d Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Thu, 1 Jan 2026 19:37:17 +0100 Subject: [PATCH] feat: add launch readiness features for OMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement VAT tax calculation on order creation based on EU country rates - Add post-order hooks: customer stats update, cart clear, email confirmation - Create shop order history page with pagination and status badges - Create shop order detail page with order items and addresses - Add order_confirmation email templates in 4 languages (en, fr, de, lb) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/shop/orders.py | 54 +++- app/services/order_service.py | 81 ++++- app/templates/shop/account/order-detail.html | 322 +++++++++++++++++++ app/templates/shop/account/orders.html | 218 ++++++++++++- scripts/seed_email_templates.py | 287 +++++++++++++++++ 5 files changed, 953 insertions(+), 9 deletions(-) create mode 100644 app/templates/shop/account/order-detail.html diff --git a/app/api/v1/shop/orders.py b/app/api/v1/shop/orders.py index 3ba4bcfa..ece4b397 100644 --- a/app/api/v1/shop/orders.py +++ b/app/api/v1/shop/orders.py @@ -12,6 +12,7 @@ Customer Context: get_current_customer_api returns Customer directly """ import logging +from datetime import UTC, datetime from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.orm import Session @@ -19,7 +20,10 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db from app.exceptions import VendorNotFoundException +from app.services.cart_service import cart_service +from app.services.email_service import EmailService from app.services.order_service import order_service +from app.utils.money import cents_to_euros from models.database.customer import Customer from models.schema.order import ( OrderCreate, @@ -81,9 +85,53 @@ def place_order( }, ) - # TODO: Update customer stats - # TODO: Clear cart - # TODO: Send order confirmation email + # Update customer stats + customer.total_orders = (customer.total_orders or 0) + 1 + customer.total_spent = (customer.total_spent or 0) + order.total_amount + customer.last_order_date = datetime.now(UTC) + db.flush() + + logger.debug( + f"Updated customer stats: total_orders={customer.total_orders}, " + f"total_spent={customer.total_spent}" + ) + + # Clear cart (get session_id from request cookies or headers) + session_id = request.cookies.get("cart_session_id") or request.headers.get( + "X-Cart-Session-Id" + ) + if session_id: + try: + cart_service.clear_cart(db, vendor.id, session_id) + logger.debug(f"Cleared cart for session {session_id}") + except Exception as e: + logger.warning(f"Failed to clear cart: {e}") + + # Send order confirmation email + try: + email_service = EmailService(db) + email_service.send_template( + template_code="order_confirmation", + to_email=customer.email, + to_name=customer.full_name, + language=customer.preferred_language or "en", + variables={ + "customer_name": customer.first_name or customer.full_name, + "order_number": order.order_number, + "order_total": f"€{order.total_amount:.2f}", + "order_items_count": len(order.items), + "order_date": order.order_date.strftime("%d.%m.%Y") + if order.order_date + else "", + "shipping_address": f"{order.ship_address_line_1}, {order.ship_postal_code} {order.ship_city}", + }, + vendor_id=vendor.id, + related_type="order", + related_id=order.id, + ) + logger.info(f"Sent order confirmation email to {customer.email}") + except Exception as e: + logger.warning(f"Failed to send order confirmation email: {e}") return OrderResponse.model_validate(order) diff --git a/app/services/order_service.py b/app/services/order_service.py index 53052c14..8660100a 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -18,6 +18,7 @@ See docs/architecture/money-handling.md for details. import logging import random import string +from decimal import Decimal from datetime import UTC, datetime from typing import Any @@ -37,6 +38,38 @@ from app.services.subscription_service import ( TierLimitExceededException, ) from app.utils.money import Money, cents_to_euros, euros_to_cents + +# EU VAT rates by country code (2024 standard rates) +# Duplicated from invoice_service to avoid circular imports +EU_VAT_RATES: dict[str, Decimal] = { + "AT": Decimal("20.00"), # Austria + "BE": Decimal("21.00"), # Belgium + "BG": Decimal("20.00"), # Bulgaria + "HR": Decimal("25.00"), # Croatia + "CY": Decimal("19.00"), # Cyprus + "CZ": Decimal("21.00"), # Czech Republic + "DK": Decimal("25.00"), # Denmark + "EE": Decimal("22.00"), # Estonia + "FI": Decimal("24.00"), # Finland + "FR": Decimal("20.00"), # France + "DE": Decimal("19.00"), # Germany + "GR": Decimal("24.00"), # Greece + "HU": Decimal("27.00"), # Hungary + "IE": Decimal("23.00"), # Ireland + "IT": Decimal("22.00"), # Italy + "LV": Decimal("21.00"), # Latvia + "LT": Decimal("21.00"), # Lithuania + "LU": Decimal("17.00"), # Luxembourg (standard) + "MT": Decimal("18.00"), # Malta + "NL": Decimal("21.00"), # Netherlands + "PL": Decimal("23.00"), # Poland + "PT": Decimal("23.00"), # Portugal + "RO": Decimal("19.00"), # Romania + "SK": Decimal("20.00"), # Slovakia + "SI": Decimal("22.00"), # Slovenia + "ES": Decimal("21.00"), # Spain + "SE": Decimal("25.00"), # Sweden +} from models.database.customer import Customer from models.database.marketplace_product import MarketplaceProduct from models.database.marketplace_product_translation import MarketplaceProductTranslation @@ -87,6 +120,50 @@ class OrderService: return order_number + # ========================================================================= + # Tax Calculation + # ========================================================================= + + def _calculate_tax_for_order( + self, subtotal_cents: int, shipping_country_iso: str + ) -> tuple[int, Decimal]: + """ + Calculate tax amount for an order based on shipping destination. + + Uses EU VAT rates based on destination country. For B2C orders, + the tax is included in the product price, but we need to calculate + the tax component for reporting. + + For Luxembourg vendors selling to EU countries: + - LU domestic: 17% VAT + - Other EU countries: destination country VAT rate + - Non-EU: 0% (VAT exempt) + + Args: + subtotal_cents: Order subtotal in cents (before tax) + shipping_country_iso: ISO 2-letter country code + + Returns: + tuple: (tax_amount_cents, vat_rate) + """ + country = shipping_country_iso.upper() if shipping_country_iso else "LU" + + # Get VAT rate for destination country (0% if non-EU) + vat_rate = EU_VAT_RATES.get(country, Decimal("0.00")) + + if vat_rate == Decimal("0.00"): + return 0, vat_rate + + # Calculate tax: tax = subtotal * (rate / 100) + # Using Decimal for precision, then converting to cents + subtotal_decimal = Decimal(str(subtotal_cents)) + tax_decimal = subtotal_decimal * (vat_rate / Decimal("100")) + + # Round to nearest cent + tax_amount_cents = int(round(tax_decimal)) + + return tax_amount_cents, vat_rate + # ========================================================================= # Placeholder Product Management # ========================================================================= @@ -369,7 +446,9 @@ class OrderService: ) # Calculate totals in cents - tax_amount_cents = 0 # TODO: Implement tax calculation + tax_amount_cents, vat_rate = self._calculate_tax_for_order( + subtotal_cents, order_data.shipping_address.country_iso + ) shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50 discount_amount_cents = 0 total_amount_cents = Money.calculate_order_total( diff --git a/app/templates/shop/account/order-detail.html b/app/templates/shop/account/order-detail.html new file mode 100644 index 00000000..370b38d6 --- /dev/null +++ b/app/templates/shop/account/order-detail.html @@ -0,0 +1,322 @@ +{# app/templates/shop/account/order-detail.html #} +{% extends "shop/base.html" %} + +{% block title %}Order Details - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}shopOrderDetailPage(){% endblock %} + +{% block content %} +
+ + + + +
+
+
+ + +
+
+ + + +
+

Error loading order

+

+ + Back to Orders + +
+
+
+ + +
+ +
+
+

+

+ Placed on +

+
+ + + +
+ +
+ +
+ + +
+
+

Order Items

+
+
+ +
+
+ + +
+ +
+

+ + + + + Shipping Address +

+
+

+

+

+

+

+

+
+
+ + +
+

+ + + + Billing Address +

+
+

+

+

+

+

+

+
+
+
+ + +
+

+ + + + Order Notes +

+

+
+
+ + +
+ +
+

Order Summary

+
+
+
Subtotal
+
+
+
+
Shipping
+
+
+
+
Tax
+
+
+
+
Discount
+
+
+
+
Total
+
+
+
+
+ + +
+

+ + + + Shipping +

+
+
+

Method

+

+
+
+

Tracking Number

+

+
+
+
+ + +
+

Need Help?

+

+ If you have any questions about your order, please contact us. +

+ + + + + Contact Support + +
+
+
+ + + +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/shop/account/orders.html b/app/templates/shop/account/orders.html index abffe4db..e76272ca 100644 --- a/app/templates/shop/account/orders.html +++ b/app/templates/shop/account/orders.html @@ -1,15 +1,223 @@ {# app/templates/shop/account/orders.html #} {% extends "shop/base.html" %} -{% block title %}Order History{% endblock %} +{% block title %}Order History - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}shopOrdersPage(){% endblock %} {% block content %}
-

Order History

+ + - {# TODO: Implement order history #} -
-

Order history coming soon...

+ +
+

Order History

+

View and track your orders

+
+ + +
+
+
+ + +
+
+ + + +

+
+
+ + +
+ + + +

No orders yet

+

Start shopping to see your orders here.

+ + Browse Products + +
+ + +
+ + + +
+ +
{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/scripts/seed_email_templates.py b/scripts/seed_email_templates.py index 1160359c..d9ed0001 100644 --- a/scripts/seed_email_templates.py +++ b/scripts/seed_email_templates.py @@ -366,6 +366,293 @@ Fir unzefänken: Mat beschte Gréiss, D'Wizamart Team +""", + }, + # ------------------------------------------------------------------------- + # ORDER CONFIRMATION + # ------------------------------------------------------------------------- + { + "code": "order_confirmation", + "language": "en", + "name": "Order Confirmation", + "description": "Sent to customers after placing an order", + "category": EmailCategory.ORDERS.value, + "variables": json.dumps([ + "customer_name", "order_number", "order_total", + "order_items_count", "order_date", "shipping_address" + ]), + "subject": "Order Confirmation - {{ order_number }}", + "body_html": """ + + + + + + +
+

Order Confirmed!

+
+ +
+

Hi {{ customer_name }},

+ +

Thank you for your order! We've received your order and it's being processed.

+ +
+

Order Details

+

Order Number: {{ order_number }}

+

Date: {{ order_date }}

+

Items: {{ order_items_count }}

+

Total: {{ order_total }}

+
+ +
+

Shipping Address

+

{{ shipping_address }}

+
+ +

You will receive another email when your order ships.

+ +

+ If you have any questions about your order, please contact us. +

+ +

Thank you for shopping with us!
The Team

+
+ +
+

This is an automated email. Please do not reply directly.

+
+ +""", + "body_text": """Order Confirmed! + +Hi {{ customer_name }}, + +Thank you for your order! We've received your order and it's being processed. + +Order Details: +- Order Number: {{ order_number }} +- Date: {{ order_date }} +- Items: {{ order_items_count }} +- Total: {{ order_total }} + +Shipping Address: +{{ shipping_address }} + +You will receive another email when your order ships. + +Thank you for shopping with us! +""", + }, + { + "code": "order_confirmation", + "language": "fr", + "name": "Confirmation de commande", + "description": "Envoyé aux clients après avoir passé une commande", + "category": EmailCategory.ORDERS.value, + "variables": json.dumps([ + "customer_name", "order_number", "order_total", + "order_items_count", "order_date", "shipping_address" + ]), + "subject": "Confirmation de commande - {{ order_number }}", + "body_html": """ + + + + + + +
+

Commande confirmée !

+
+ +
+

Bonjour {{ customer_name }},

+ +

Merci pour votre commande ! Nous l'avons bien reçue et elle est en cours de traitement.

+ +
+

Détails de la commande

+

Numéro de commande : {{ order_number }}

+

Date : {{ order_date }}

+

Articles : {{ order_items_count }}

+

Total : {{ order_total }}

+
+ +
+

Adresse de livraison

+

{{ shipping_address }}

+
+ +

Vous recevrez un autre email lors de l'expédition de votre commande.

+ +

+ Si vous avez des questions concernant votre commande, n'hésitez pas à nous contacter. +

+ +

Merci pour votre achat !
L'équipe

+
+ +""", + "body_text": """Commande confirmée ! + +Bonjour {{ customer_name }}, + +Merci pour votre commande ! Nous l'avons bien reçue et elle est en cours de traitement. + +Détails de la commande : +- Numéro de commande : {{ order_number }} +- Date : {{ order_date }} +- Articles : {{ order_items_count }} +- Total : {{ order_total }} + +Adresse de livraison : +{{ shipping_address }} + +Vous recevrez un autre email lors de l'expédition de votre commande. + +Merci pour votre achat ! +""", + }, + { + "code": "order_confirmation", + "language": "de", + "name": "Bestellbestätigung", + "description": "An Kunden nach einer Bestellung gesendet", + "category": EmailCategory.ORDERS.value, + "variables": json.dumps([ + "customer_name", "order_number", "order_total", + "order_items_count", "order_date", "shipping_address" + ]), + "subject": "Bestellbestätigung - {{ order_number }}", + "body_html": """ + + + + + + +
+

Bestellung bestätigt!

+
+ +
+

Hallo {{ customer_name }},

+ +

Vielen Dank fĂĽr Ihre Bestellung! Wir haben Ihre Bestellung erhalten und sie wird bearbeitet.

+ +
+

Bestelldetails

+

Bestellnummer: {{ order_number }}

+

Datum: {{ order_date }}

+

Artikel: {{ order_items_count }}

+

Summe: {{ order_total }}

+
+ +
+

Lieferadresse

+

{{ shipping_address }}

+
+ +

Sie erhalten eine weitere E-Mail, sobald Ihre Bestellung versandt wird.

+ +

+ Bei Fragen zu Ihrer Bestellung kontaktieren Sie uns bitte. +

+ +

Vielen Dank fĂĽr Ihren Einkauf!
Das Team

+
+ +""", + "body_text": """Bestellung bestätigt! + +Hallo {{ customer_name }}, + +Vielen Dank für Ihre Bestellung! Wir haben Ihre Bestellung erhalten und sie wird bearbeitet. + +Bestelldetails: +- Bestellnummer: {{ order_number }} +- Datum: {{ order_date }} +- Artikel: {{ order_items_count }} +- Summe: {{ order_total }} + +Lieferadresse: +{{ shipping_address }} + +Sie erhalten eine weitere E-Mail, sobald Ihre Bestellung versandt wird. + +Vielen Dank für Ihren Einkauf! +""", + }, + { + "code": "order_confirmation", + "language": "lb", + "name": "Bestellung Konfirmatioun", + "description": "Un Clienten no enger Bestellung geschéckt", + "category": EmailCategory.ORDERS.value, + "variables": json.dumps([ + "customer_name", "order_number", "order_total", + "order_items_count", "order_date", "shipping_address" + ]), + "subject": "Bestellung Konfirmatioun - {{ order_number }}", + "body_html": """ + + + + + + +
+

Bestellung confirméiert!

+
+ +
+

Moien {{ customer_name }},

+ +

Merci fir Är Bestellung! Mir hunn Är Bestellung kritt an si gëtt beaarbecht.

+ +
+

Bestelldetailer

+

Bestellnummer: {{ order_number }}

+

Datum: {{ order_date }}

+

Artikelen: {{ order_items_count }}

+

Total: {{ order_total }}

+
+ +
+

Liwweradress

+

{{ shipping_address }}

+
+ +

Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt.

+ +

+ Wann Dir Froen iwwer Är Bestellung hutt, kontaktéiert eis w.e.g. +

+ +

Merci fir Ären Akaf!
D'Team

+
+ +""", + "body_text": """Bestellung confirméiert! + +Moien {{ customer_name }}, + +Merci fir Är Bestellung! Mir hunn Är Bestellung kritt an si gëtt beaarbecht. + +Bestelldetailer: +- Bestellnummer: {{ order_number }} +- Datum: {{ order_date }} +- Artikelen: {{ order_items_count }} +- Total: {{ order_total }} + +Liwweradress: +{{ shipping_address }} + +Dir kritt eng weider E-Mail wann Är Bestellung verschéckt gëtt. + +Merci fir Ären Akaf! """, }, ]