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 %} +
+ Placed on +
++ SKU: +
++ Qty: + × + +
+Method
+ +Tracking Number
+ ++ If you have any questions about your order, please contact us. +
+ + + Contact Support + +Order history coming soon...
+ +View and track your orders
+Order Number
+ +Date
+ +Total
+ ++ Qty: × + +
+Hi {{ customer_name }},
+ +Thank you for your order! We've received your order and it's being processed.
+ +Order Number: {{ order_number }}
+Date: {{ order_date }}
+Items: {{ order_items_count }}
+Total: {{ order_total }}
+{{ 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.
+Bonjour {{ customer_name }},
+ +Merci pour votre commande ! Nous l'avons bien reçue et elle est en cours de traitement.
+ +Numéro de commande : {{ order_number }}
+Date : {{ order_date }}
+Articles : {{ order_items_count }}
+Total : {{ order_total }}
+{{ 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
Hallo {{ customer_name }},
+ +Vielen Dank für Ihre Bestellung! Wir haben Ihre Bestellung erhalten und sie wird bearbeitet.
+ +Bestellnummer: {{ order_number }}
+Datum: {{ order_date }}
+Artikel: {{ order_items_count }}
+Summe: {{ order_total }}
+{{ 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
Moien {{ customer_name }},
+ +Merci fir Är Bestellung! Mir hunn Är Bestellung kritt an si gëtt beaarbecht.
+ +Bestellnummer: {{ order_number }}
+Datum: {{ order_date }}
+Artikelen: {{ order_items_count }}
+Total: {{ order_total }}
+{{ 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