feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
CI / ruff (push) Successful in 12s
CI / pytest (push) Successful in 50m57s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 40s
CI / deploy (push) Successful in 51s

- Fix platform-grouped merchant sidebar menu with core items at root level
- Add merchant store management (detail page, create store, team page)
- Fix store settings 500 error by removing dead stripe/API tab
- Move onboarding translations to module-owned locale files
- Fix onboarding banner i18n with server-side rendering + context inheritance
- Refactor login language selectors to use languageSelector() function (LANG-002)
- Move HTTPException handling to global exception handler in merchant routes (API-003)
- Add language selector to all login pages and portal headers
- Fix customer module: drop order stats from customer model, add to orders module
- Fix admin menu config visibility for super admin platform context
- Fix storefront auth and layout issues
- Add missing i18n translations for onboarding steps (en/fr/de/lb)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 23:48:25 +01:00
parent f141cc4e6a
commit a77a8a3a98
113 changed files with 3741 additions and 2923 deletions

View File

@@ -0,0 +1,35 @@
"""orders 002 - customer order stats table
Revision ID: orders_002
Revises: orders_001
Create Date: 2026-03-07
"""
import sqlalchemy as sa
from alembic import op
revision = "orders_002"
down_revision = "orders_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"customer_order_stats",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True),
sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id"), nullable=False, index=True),
sa.Column("total_orders", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_spent_cents", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_order_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_order_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("store_id", "customer_id", name="uq_customer_order_stats_store_customer"),
)
op.create_index("idx_customer_order_stats_customer", "customer_order_stats", ["customer_id"])
def downgrade() -> None:
op.drop_table("customer_order_stats")

View File

@@ -5,6 +5,7 @@ Orders module database models.
This module contains the canonical implementations of order-related models.
"""
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
from app.modules.orders.models.invoice import (
Invoice,
InvoiceStatus,
@@ -15,6 +16,7 @@ from app.modules.orders.models.order import Order, OrderItem
from app.modules.orders.models.order_item_exception import OrderItemException
__all__ = [
"CustomerOrderStats",
"Order",
"OrderItem",
"OrderItemException",

View File

@@ -0,0 +1,52 @@
# app/modules/orders/models/customer_order_stats.py
"""
Customer order statistics model.
Tracks per-customer order aggregates (total orders, total spent, etc.)
owned by the orders module. This separates order stats from the
customer profile data owned by the customers module.
"""
from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class CustomerOrderStats(Base, TimestampMixin):
"""Aggregated order statistics per customer per store."""
__tablename__ = "customer_order_stats"
id = Column(Integer, primary_key=True, index=True)
store_id = Column(
Integer, ForeignKey("stores.id"), nullable=False, index=True
)
customer_id = Column(
Integer, ForeignKey("customers.id"), nullable=False, index=True
)
total_orders = Column(Integer, nullable=False, default=0)
total_spent_cents = Column(Integer, nullable=False, default=0)
last_order_date = Column(DateTime(timezone=True), nullable=True)
first_order_date = Column(DateTime(timezone=True), nullable=True)
# Relationships
store = relationship("Store")
customer = relationship("Customer")
__table_args__ = (
UniqueConstraint(
"store_id", "customer_id", name="uq_customer_order_stats_store_customer"
),
Index("idx_customer_order_stats_customer", "customer_id"),
)
def __repr__(self):
return (
f"<CustomerOrderStats("
f"store_id={self.store_id}, "
f"customer_id={self.customer_id}, "
f"total_orders={self.total_orders}, "
f"total_spent_cents={self.total_spent_cents})>"
)

View File

@@ -13,10 +13,11 @@ Similar to how:
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.modules.orders.models import Order
from app.modules.orders.models import CustomerOrderStats, Order
logger = logging.getLogger(__name__)
@@ -89,6 +90,51 @@ class CustomerOrderService:
.all()
)
def record_order(
self,
db: Session,
store_id: int,
customer_id: int,
total_amount_cents: int,
) -> CustomerOrderStats:
"""
Record an order in customer order stats (upsert).
Args:
db: Database session
store_id: Store ID
customer_id: Customer ID
total_amount_cents: Order total in cents
Returns:
Updated CustomerOrderStats
"""
stats = (
db.query(CustomerOrderStats)
.filter(
CustomerOrderStats.store_id == store_id,
CustomerOrderStats.customer_id == customer_id,
)
.first()
)
now = datetime.now(UTC)
if stats:
stats.total_orders = (stats.total_orders or 0) + 1
stats.total_spent_cents = (stats.total_spent_cents or 0) + total_amount_cents
stats.last_order_date = now
else:
stats = CustomerOrderStats(
store_id=store_id,
customer_id=customer_id,
total_orders=1,
total_spent_cents=total_amount_cents,
first_order_date=now,
last_order_date=now,
)
db.add(stats)
db.flush()
return stats
def get_order_count(
self,
db: Session,

View File

@@ -0,0 +1,84 @@
# tests/unit/models/test_customer_order_stats.py
"""Unit tests for CustomerOrderStats model."""
import pytest
from sqlalchemy.exc import IntegrityError
from app.modules.orders.models.customer_order_stats import CustomerOrderStats
@pytest.mark.unit
@pytest.mark.database
class TestCustomerOrderStatsModel:
"""Test CustomerOrderStats model."""
def test_create_stats(self, db, test_store, test_customer):
"""Test creating customer order stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=3,
total_spent_cents=15000,
)
db.add(stats)
db.commit()
db.refresh(stats)
assert stats.id is not None
assert stats.store_id == test_store.id
assert stats.customer_id == test_customer.id
assert stats.total_orders == 3
assert stats.total_spent_cents == 15000
def test_default_values(self, db, test_store, test_customer):
"""Test default values for stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
)
db.add(stats)
db.commit()
db.refresh(stats)
assert stats.total_orders == 0
assert stats.total_spent_cents == 0
assert stats.last_order_date is None
assert stats.first_order_date is None
def test_unique_constraint(self, db, test_store, test_customer):
"""Test unique constraint on store_id + customer_id."""
stats1 = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=1,
)
db.add(stats1)
db.commit()
stats2 = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=2,
)
db.add(stats2)
with pytest.raises(IntegrityError):
db.commit()
def test_update_stats(self, db, test_store, test_customer):
"""Test updating existing stats."""
stats = CustomerOrderStats(
store_id=test_store.id,
customer_id=test_customer.id,
total_orders=1,
total_spent_cents=5000,
)
db.add(stats)
db.commit()
stats.total_orders = 2
stats.total_spent_cents = 12000
db.commit()
db.refresh(stats)
assert stats.total_orders == 2
assert stats.total_spent_cents == 12000