fix(billing): complete billing module — fix tier change, platform support, merchant portal

- Fix admin tier change: resolve tier_code→tier_id in update_subscription(),
  delegate to billing_service.change_tier() for Stripe-connected subs
- Add platform support to admin tiers page: platform column, filter dropdown,
  platform selector in create/edit modal, platform_name in tier API response
- Filter used platforms in create subscription modal on merchant detail page
- Enrich merchant portal API responses with tier code, tier_name, platform_name
- Add eager-load of platform relationship in get_merchant_subscription()
- Remove stale store_name/store_code references from merchant templates
- Add merchant tier change endpoint (POST /change-tier) and tier selector UI
  replacing broken requestUpgrade() button
- Fix subscription detail link to use platform_id instead of sub.id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 20:49:48 +01:00
parent 0b37274140
commit d1fe3584ff
54 changed files with 222 additions and 52 deletions

View File

@@ -0,0 +1,402 @@
# tests/unit/models/database/test_product.py
"""Unit tests for Product (store catalog) database model."""
import pytest
from sqlalchemy.exc import IntegrityError
from app.modules.catalog.models import Product
@pytest.mark.unit
@pytest.mark.database
class TestProductModel:
"""Test Product (store catalog) model."""
def test_product_creation(self, db, test_store, test_marketplace_product):
"""Test Product model linking store catalog to marketplace product."""
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
store_sku="STORE_PROD_001",
price=89.99,
currency="EUR",
availability="in stock",
is_featured=True,
is_active=True,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.id is not None
assert product.store_id == test_store.id
assert product.marketplace_product_id == test_marketplace_product.id
assert product.price == 89.99
assert product.is_featured is True
assert product.store.store_code == test_store.store_code
# Use get_title() method instead of .title attribute
assert product.marketplace_product.get_title(
"en"
) == test_marketplace_product.get_title("en")
def test_product_unique_per_store(self, db, test_store, test_marketplace_product):
"""Test that same marketplace product can't be added twice to store catalog."""
product1 = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
is_active=True,
)
db.add(product1)
db.commit()
# Same marketplace product to same store should fail
with pytest.raises(IntegrityError):
product2 = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
is_active=True,
)
db.add(product2)
db.commit()
def test_product_default_values(self, db, test_store, test_marketplace_product):
"""Test Product model default values."""
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.is_active is True # Default
assert product.is_featured is False # Default
assert product.is_digital is False # Default
assert product.product_type == "physical" # Default
assert product.min_quantity == 1 # Default
assert product.display_order == 0 # Default
def test_product_store_override_fields(
self, db, test_store, test_marketplace_product
):
"""Test Product model store-specific override fields."""
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
store_sku="CUSTOM_SKU_001",
price=49.99,
sale_price=39.99,
currency="USD",
availability="limited",
condition="new",
)
db.add(product)
db.commit()
db.refresh(product)
assert product.store_sku == "CUSTOM_SKU_001"
assert product.price == 49.99
assert product.sale_price == 39.99
assert product.currency == "USD"
assert product.availability == "limited"
def test_product_inventory_settings(
self, db, test_store, test_marketplace_product
):
"""Test Product model inventory settings."""
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
min_quantity=2,
max_quantity=10,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.min_quantity == 2
assert product.max_quantity == 10
def test_product_relationships(self, db, test_store, test_marketplace_product):
"""Test Product relationships."""
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.store is not None
assert product.marketplace_product is not None
assert product.inventory_entries == [] # No inventory yet
def test_product_get_source_comparison_info(
self, db, test_store, test_marketplace_product
):
"""Test get_source_comparison_info method for 'view original source' feature.
Products are independent entities with all fields populated at creation.
Source values are kept for comparison only, not inheritance.
"""
# Set up marketplace product values
test_marketplace_product.price_cents = 10000 # €100.00
test_marketplace_product.brand = "SourceBrand"
db.commit()
# Create product with its own values (independent copy pattern)
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
price_cents=8999, # €89.99 - store's price
brand="StoreBrand", # Store's brand
)
db.add(product)
db.commit()
db.refresh(product)
info = product.get_source_comparison_info()
# Product has its own price
assert info["price"] == 89.99
assert info["price_cents"] == 8999
assert info["price_source"] == 100.00 # Original marketplace price
# Product has its own brand
assert info["brand"] == "StoreBrand"
assert info["brand_source"] == "SourceBrand" # Original marketplace brand
# No more *_overridden keys in the pattern
assert "price_overridden" not in info
assert "brand_overridden" not in info
def test_product_fields_are_independent(
self, db, test_store, test_marketplace_product
):
"""Test that product fields don't inherit from marketplace product.
Products are independent entities - NULL fields stay NULL,
no inheritance/fallback logic.
"""
# Set up marketplace product values
test_marketplace_product.price_cents = 10000
test_marketplace_product.brand = "SourceBrand"
db.commit()
# Create product without copying values
product = Product(
store_id=test_store.id,
marketplace_product_id=test_marketplace_product.id,
# Not copying price_cents or brand
)
db.add(product)
db.commit()
db.refresh(product)
# Fields should be NULL (not inherited)
assert product.price_cents is None
assert product.price is None
assert product.brand is None
# But we can still see the source values for comparison
info = product.get_source_comparison_info()
assert info["price_source"] == 100.00
assert info["brand_source"] == "SourceBrand"
def test_product_direct_creation_without_marketplace(self, db, test_store):
"""Test creating a product directly without a marketplace source.
Products can be created directly without a marketplace_product_id,
making them fully independent store products.
"""
product = Product(
store_id=test_store.id,
marketplace_product_id=None, # No marketplace source
store_sku="DIRECT_001",
brand="DirectBrand",
price=59.99,
currency="EUR",
is_digital=True,
product_type="digital",
is_active=True,
)
db.add(product)
db.commit()
db.refresh(product)
assert product.id is not None
assert product.marketplace_product_id is None
assert product.marketplace_product is None
assert product.store_sku == "DIRECT_001"
assert product.brand == "DirectBrand"
assert product.is_digital is True
assert product.product_type == "digital"
def test_product_is_digital_column(self, db, test_store):
"""Test is_digital is an independent column, not derived from marketplace."""
# Create digital product without marketplace source
digital_product = Product(
store_id=test_store.id,
store_sku="DIGITAL_001",
is_digital=True,
product_type="digital",
)
db.add(digital_product)
db.commit()
db.refresh(digital_product)
assert digital_product.is_digital is True
assert digital_product.product_type == "digital"
# Create physical product without marketplace source
physical_product = Product(
store_id=test_store.id,
store_sku="PHYSICAL_001",
is_digital=False,
product_type="physical",
)
db.add(physical_product)
db.commit()
db.refresh(physical_product)
assert physical_product.is_digital is False
assert physical_product.product_type == "physical"
def test_product_type_values(self, db, test_store):
"""Test product_type can be set to various values."""
product_types = ["physical", "digital", "service", "subscription"]
for ptype in product_types:
product = Product(
store_id=test_store.id,
store_sku=f"TYPE_{ptype.upper()}",
product_type=ptype,
is_digital=(ptype == "digital"),
)
db.add(product)
db.commit()
db.refresh(product)
assert product.product_type == ptype
@pytest.mark.unit
@pytest.mark.database
@pytest.mark.inventory
class TestProductInventoryProperties:
"""Test Product inventory properties including digital product handling."""
def test_physical_product_no_inventory_returns_zero(self, db, test_store):
"""Test physical product with no inventory entries returns 0."""
product = Product(
store_id=test_store.id,
store_sku="PHYS_INV_001",
is_digital=False,
product_type="physical",
)
db.add(product)
db.commit()
db.refresh(product)
assert product.is_digital is False
assert product.has_unlimited_inventory is False
assert product.total_inventory == 0
assert product.available_inventory == 0
def test_physical_product_with_inventory(self, db, test_store):
"""Test physical product calculates inventory from entries."""
from app.modules.inventory.models import Inventory
product = Product(
store_id=test_store.id,
store_sku="PHYS_INV_002",
is_digital=False,
product_type="physical",
)
db.add(product)
db.commit()
db.refresh(product)
# Add inventory entries
inv1 = Inventory(
product_id=product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-01-01",
location="WAREHOUSE_A",
quantity=100,
reserved_quantity=10,
)
inv2 = Inventory(
product_id=product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-01-02",
location="WAREHOUSE_B",
quantity=50,
reserved_quantity=5,
)
db.add_all([inv1, inv2])
db.commit()
db.refresh(product)
assert product.has_unlimited_inventory is False
assert product.total_inventory == 150 # 100 + 50
assert product.available_inventory == 135 # (100-10) + (50-5)
def test_digital_product_has_unlimited_inventory(self, db, test_store):
"""Test digital product returns unlimited inventory."""
product = Product(
store_id=test_store.id,
store_sku="DIG_INV_001",
is_digital=True,
product_type="digital",
)
db.add(product)
db.commit()
db.refresh(product)
assert product.is_digital is True
assert product.has_unlimited_inventory is True
assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY
def test_digital_product_ignores_inventory_entries(self, db, test_store):
"""Test digital product returns unlimited even with inventory entries."""
from app.modules.inventory.models import Inventory
product = Product(
store_id=test_store.id,
store_sku="DIG_INV_002",
is_digital=True,
product_type="digital",
)
db.add(product)
db.commit()
db.refresh(product)
# Add inventory entries (e.g., for license keys)
inv = Inventory(
product_id=product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="DIG-01-01",
location="DIGITAL_LICENSES",
quantity=10,
reserved_quantity=2,
)
db.add(inv)
db.commit()
db.refresh(product)
# Digital product should still return unlimited
assert product.has_unlimited_inventory is True
assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY
def test_unlimited_inventory_constant(self):
"""Test UNLIMITED_INVENTORY constant value."""
assert Product.UNLIMITED_INVENTORY == 999999
# Should be large enough to never cause "insufficient inventory"
assert Product.UNLIMITED_INVENTORY > 100000

View File

@@ -0,0 +1,245 @@
# tests/unit/models/schema/test_product.py
"""Unit tests for product Pydantic schemas."""
import pytest
from pydantic import ValidationError
from app.modules.catalog.schemas import (
ProductCreate,
ProductListResponse,
ProductResponse,
ProductUpdate,
)
@pytest.mark.unit
@pytest.mark.schema
class TestProductCreateSchema:
"""Test ProductCreate schema validation."""
def test_valid_product_create(self):
"""Test valid product creation data."""
product = ProductCreate(
marketplace_product_id=1,
store_sku="SKU-001",
price=99.99,
currency="EUR",
)
assert product.marketplace_product_id == 1
assert product.store_sku == "SKU-001"
assert product.price == 99.99
def test_marketplace_product_id_required(self):
"""Test marketplace_product_id is required."""
with pytest.raises(ValidationError) as exc_info:
ProductCreate(price=99.99)
assert "marketplace_product_id" in str(exc_info.value).lower()
def test_price_must_be_non_negative(self):
"""Test price must be >= 0."""
with pytest.raises(ValidationError) as exc_info:
ProductCreate(
marketplace_product_id=1,
price=-10.00,
)
assert "price" in str(exc_info.value).lower()
def test_price_zero_is_valid(self):
"""Test price of 0 is valid."""
product = ProductCreate(
marketplace_product_id=1,
price=0,
)
assert product.price == 0
def test_sale_price_must_be_non_negative(self):
"""Test sale_price must be >= 0."""
with pytest.raises(ValidationError) as exc_info:
ProductCreate(
marketplace_product_id=1,
sale_price=-5.00,
)
assert "sale_price" in str(exc_info.value).lower()
def test_min_quantity_default(self):
"""Test min_quantity defaults to 1."""
product = ProductCreate(marketplace_product_id=1)
assert product.min_quantity == 1
def test_min_quantity_must_be_at_least_1(self):
"""Test min_quantity must be >= 1."""
with pytest.raises(ValidationError) as exc_info:
ProductCreate(
marketplace_product_id=1,
min_quantity=0,
)
assert "min_quantity" in str(exc_info.value).lower()
def test_max_quantity_must_be_at_least_1(self):
"""Test max_quantity must be >= 1 if provided."""
with pytest.raises(ValidationError) as exc_info:
ProductCreate(
marketplace_product_id=1,
max_quantity=0,
)
assert "max_quantity" in str(exc_info.value).lower()
def test_is_featured_default(self):
"""Test is_featured defaults to False."""
product = ProductCreate(marketplace_product_id=1)
assert product.is_featured is False
def test_all_optional_fields(self):
"""Test product with all optional fields."""
product = ProductCreate(
marketplace_product_id=1,
store_sku="SKU-001",
price=100.00,
sale_price=80.00,
currency="EUR",
availability="in_stock",
condition="new",
is_featured=True,
min_quantity=2,
max_quantity=10,
)
assert product.sale_price == 80.00
assert product.availability == "in_stock"
assert product.condition == "new"
assert product.is_featured is True
assert product.max_quantity == 10
@pytest.mark.unit
@pytest.mark.schema
class TestProductUpdateSchema:
"""Test ProductUpdate schema validation."""
def test_partial_update(self):
"""Test partial update with only some fields."""
update = ProductUpdate(price=150.00)
assert update.price == 150.00
assert update.store_sku is None
assert update.is_active is None
def test_empty_update_is_valid(self):
"""Test empty update is valid (all fields optional)."""
update = ProductUpdate()
assert update.model_dump(exclude_unset=True) == {}
def test_price_validation(self):
"""Test price must be >= 0 in update."""
with pytest.raises(ValidationError):
ProductUpdate(price=-10.00)
def test_is_active_update(self):
"""Test is_active can be updated."""
update = ProductUpdate(is_active=False)
assert update.is_active is False
def test_is_featured_update(self):
"""Test is_featured can be updated."""
update = ProductUpdate(is_featured=True)
assert update.is_featured is True
@pytest.mark.unit
@pytest.mark.schema
class TestProductResponseSchema:
"""Test ProductResponse schema."""
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
data = {
"id": 1,
"store_id": 1,
"marketplace_product": {
"id": 1,
"marketplace_product_id": "TEST001", # Required field
"gtin": "1234567890123",
"title": "Test Product",
"description": "A test product",
"brand": "Test Brand",
"google_product_category": "Electronics",
"image_link": "https://example.com/image.jpg",
"created_at": datetime.now(),
"updated_at": datetime.now(),
},
"store_sku": "SKU-001",
"price": 99.99,
"sale_price": None,
"currency": "EUR",
"availability": "in_stock",
"condition": "new",
"is_featured": False,
"is_active": True,
"display_order": 0,
"min_quantity": 1,
"max_quantity": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = ProductResponse(**data)
assert response.id == 1
assert response.store_id == 1
assert response.is_active is True
def test_optional_inventory_fields(self):
"""Test optional inventory summary fields."""
from datetime import datetime
data = {
"id": 1,
"store_id": 1,
"marketplace_product": {
"id": 1,
"marketplace_product_id": "TEST002", # Required field
"gtin": "1234567890123",
"title": "Test Product",
"description": None,
"brand": None,
"google_product_category": None,
"image_link": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
},
"store_sku": None,
"price": None,
"sale_price": None,
"currency": None,
"availability": None,
"condition": None,
"is_featured": False,
"is_active": True,
"display_order": 0,
"min_quantity": 1,
"max_quantity": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
"total_inventory": 100,
"available_inventory": 80,
}
response = ProductResponse(**data)
assert response.total_inventory == 100
assert response.available_inventory == 80
@pytest.mark.unit
@pytest.mark.schema
class TestProductListResponseSchema:
"""Test ProductListResponse schema."""
def test_valid_list_response(self):
"""Test valid list response structure."""
response = ProductListResponse(
products=[],
total=0,
skip=0,
limit=10,
)
assert response.products == []
assert response.total == 0
assert response.skip == 0
assert response.limit == 10

View File

@@ -0,0 +1,129 @@
# tests/unit/services/test_store_product_service.py
"""
Unit tests for StoreProductService.
Tests the store product catalog service operations.
"""
import pytest
from app.modules.catalog.exceptions import ProductNotFoundException
from app.modules.catalog.services.store_product_service import StoreProductService
@pytest.mark.unit
@pytest.mark.products
class TestStoreProductService:
"""Tests for StoreProductService."""
def setup_method(self):
self.service = StoreProductService()
def test_get_products_success(self, db, test_product):
"""Test getting store products list."""
products, total = self.service.get_products(db)
assert total >= 1
assert len(products) >= 1
# Find our test product in results
found = False
for p in products:
if p["id"] == test_product.id:
found = True
assert p["store_id"] == test_product.store_id
assert (
p["marketplace_product_id"] == test_product.marketplace_product_id
)
break
assert found, "Test product not found in results"
def test_get_products_with_store_filter(self, db, test_product, test_store):
"""Test getting products filtered by store."""
products, total = self.service.get_products(db, store_id=test_store.id)
assert total >= 1
# All products should be from the filtered store
for p in products:
assert p["store_id"] == test_store.id
def test_get_products_with_active_filter(self, db, test_product):
"""Test getting products filtered by active status."""
products, total = self.service.get_products(db, is_active=True)
# All products should be active
for p in products:
assert p["is_active"] is True
def test_get_products_with_featured_filter(self, db, test_product):
"""Test getting products filtered by featured status."""
products, total = self.service.get_products(db, is_featured=False)
# All products should not be featured
for p in products:
assert p["is_featured"] is False
def test_get_products_pagination(self, db, test_product):
"""Test store products pagination."""
products, total = self.service.get_products(db, skip=0, limit=10)
assert total >= 1
assert len(products) <= 10
def test_get_product_stats_success(self, db, test_product):
"""Test getting store product statistics."""
stats = self.service.get_product_stats(db)
assert "total" in stats
assert "active" in stats
assert "inactive" in stats
assert "featured" in stats
assert "digital" in stats
assert "physical" in stats
assert "by_store" in stats
assert stats["total"] >= 1
def test_get_catalog_stores_success(self, db, test_product, test_store):
"""Test getting list of stores with products."""
stores = self.service.get_catalog_stores(db)
assert isinstance(stores, list)
assert len(stores) >= 1
# Check that test_store is in the list
store_ids = [v["id"] for v in stores]
assert test_store.id in store_ids
def test_get_product_detail_success(self, db, test_product):
"""Test getting store product detail."""
product = self.service.get_product_detail(db, test_product.id)
assert product["id"] == test_product.id
assert product["store_id"] == test_product.store_id
assert product["marketplace_product_id"] == test_product.marketplace_product_id
assert "source_marketplace" in product
assert "source_store" in product
def test_get_product_detail_not_found(self, db):
"""Test getting non-existent product raises exception."""
with pytest.raises(ProductNotFoundException):
self.service.get_product_detail(db, 99999)
def test_remove_product_success(self, db, test_product):
"""Test removing product from store catalog."""
product_id = test_product.id
result = self.service.remove_product(db, product_id)
assert "message" in result
assert "removed" in result["message"].lower()
# Verify product is removed
with pytest.raises(ProductNotFoundException):
self.service.get_product_detail(db, product_id)
def test_remove_product_not_found(self, db):
"""Test removing non-existent product raises exception."""
with pytest.raises(ProductNotFoundException):
self.service.remove_product(db, 99999)