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,165 @@
# tests/unit/models/database/test_inventory.py
"""Unit tests for Inventory database model."""
import pytest
from sqlalchemy.exc import IntegrityError
from app.modules.inventory.models import Inventory
@pytest.mark.unit
@pytest.mark.database
class TestInventoryModel:
"""Test Inventory model."""
def test_inventory_creation_with_product(self, db, test_store, test_product):
"""Test Inventory model linked to product."""
inventory = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=150,
reserved_quantity=10,
gtin=test_product.marketplace_product.gtin,
)
db.add(inventory)
db.commit()
db.refresh(inventory)
assert inventory.id is not None
assert inventory.product_id == test_product.id
assert inventory.store_id == test_store.id
assert inventory.location == "WAREHOUSE_A"
assert inventory.bin_location == "SA-10-01"
assert inventory.quantity == 150
assert inventory.reserved_quantity == 10
assert inventory.available_quantity == 140 # 150 - 10
def test_inventory_unique_product_location(self, db, test_store, test_product):
"""Test unique constraint on product_id + warehouse + bin_location."""
inventory1 = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=100,
)
db.add(inventory1)
db.commit()
# Same product + warehouse + bin_location should fail
with pytest.raises(IntegrityError):
inventory2 = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=50,
)
db.add(inventory2)
db.commit()
def test_inventory_same_product_different_location(
self, db, test_store, test_product
):
"""Test same product can have inventory in different locations."""
inventory1 = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-10-01",
location="WAREHOUSE_A",
quantity=100,
)
db.add(inventory1)
db.commit()
# Same product in different bin_location should succeed
inventory2 = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="SA-10-02",
location="WAREHOUSE_B",
quantity=50,
)
db.add(inventory2)
db.commit()
db.refresh(inventory2)
assert inventory2.id is not None
assert inventory2.bin_location == "SA-10-02"
def test_inventory_default_values(self, db, test_store, test_product):
"""Test Inventory model default values."""
inventory = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="DEF-01-01",
location="DEFAULT_LOC",
quantity=100,
)
db.add(inventory)
db.commit()
db.refresh(inventory)
assert inventory.reserved_quantity == 0 # Default
assert inventory.available_quantity == 100 # quantity - reserved
def test_inventory_available_quantity_property(self, db, test_store, test_product):
"""Test available_quantity computed property."""
inventory = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="PROP-01-01",
location="PROP_TEST",
quantity=200,
reserved_quantity=50,
)
db.add(inventory)
db.commit()
db.refresh(inventory)
assert inventory.available_quantity == 150 # 200 - 50
def test_inventory_relationships(self, db, test_store, test_product):
"""Test Inventory relationships."""
inventory = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="REL-01-01",
location="REL_TEST",
quantity=100,
)
db.add(inventory)
db.commit()
db.refresh(inventory)
assert inventory.product is not None
assert inventory.store is not None
assert inventory.product.id == test_product.id
assert inventory.store.id == test_store.id
def test_inventory_without_gtin(self, db, test_store, test_product):
"""Test Inventory can be created without GTIN."""
inventory = Inventory(
product_id=test_product.id,
store_id=test_store.id,
warehouse="strassen",
bin_location="NOGTIN-01-01",
location="NO_GTIN",
quantity=100,
)
db.add(inventory)
db.commit()
db.refresh(inventory)
assert inventory.gtin is None

View File

@@ -0,0 +1,299 @@
# tests/unit/models/schema/test_inventory.py
"""Unit tests for inventory Pydantic schemas."""
import pytest
from pydantic import ValidationError
from app.modules.inventory.schemas import (
InventoryAdjust,
InventoryCreate,
InventoryLocationResponse,
InventoryReserve,
InventoryResponse,
InventoryUpdate,
ProductInventorySummary,
)
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryCreateSchema:
"""Test InventoryCreate schema validation."""
def test_valid_inventory_create(self):
"""Test valid inventory creation data."""
inventory = InventoryCreate(
product_id=1,
location="Warehouse A",
quantity=100,
)
assert inventory.product_id == 1
assert inventory.location == "Warehouse A"
assert inventory.quantity == 100
def test_product_id_required(self):
"""Test product_id is required."""
with pytest.raises(ValidationError) as exc_info:
InventoryCreate(location="Warehouse A", quantity=10)
assert "product_id" in str(exc_info.value).lower()
def test_location_required(self):
"""Test location is required."""
with pytest.raises(ValidationError) as exc_info:
InventoryCreate(product_id=1, quantity=10)
assert "location" in str(exc_info.value).lower()
def test_quantity_required(self):
"""Test quantity is required."""
with pytest.raises(ValidationError) as exc_info:
InventoryCreate(product_id=1, location="Warehouse A")
assert "quantity" in str(exc_info.value).lower()
def test_quantity_must_be_non_negative(self):
"""Test quantity must be >= 0."""
with pytest.raises(ValidationError) as exc_info:
InventoryCreate(
product_id=1,
location="Warehouse A",
quantity=-5,
)
assert "quantity" in str(exc_info.value).lower()
def test_quantity_zero_is_valid(self):
"""Test quantity of 0 is valid."""
inventory = InventoryCreate(
product_id=1,
location="Warehouse A",
quantity=0,
)
assert inventory.quantity == 0
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryAdjustSchema:
"""Test InventoryAdjust schema validation."""
def test_positive_adjustment(self):
"""Test positive quantity adjustment (add stock)."""
adjustment = InventoryAdjust(
product_id=1,
location="Warehouse A",
quantity=50,
)
assert adjustment.quantity == 50
def test_negative_adjustment(self):
"""Test negative quantity adjustment (remove stock)."""
adjustment = InventoryAdjust(
product_id=1,
location="Warehouse A",
quantity=-20,
)
assert adjustment.quantity == -20
def test_zero_adjustment_is_valid(self):
"""Test zero adjustment is technically valid."""
adjustment = InventoryAdjust(
product_id=1,
location="Warehouse A",
quantity=0,
)
assert adjustment.quantity == 0
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryUpdateSchema:
"""Test InventoryUpdate schema validation."""
def test_partial_update(self):
"""Test partial update with only some fields."""
update = InventoryUpdate(quantity=150)
assert update.quantity == 150
assert update.reserved_quantity is None
assert update.location is None
def test_empty_update_is_valid(self):
"""Test empty update is valid."""
update = InventoryUpdate()
assert update.model_dump(exclude_unset=True) == {}
def test_quantity_must_be_non_negative(self):
"""Test quantity must be >= 0 in update."""
with pytest.raises(ValidationError):
InventoryUpdate(quantity=-10)
def test_reserved_quantity_must_be_non_negative(self):
"""Test reserved_quantity must be >= 0."""
with pytest.raises(ValidationError):
InventoryUpdate(reserved_quantity=-5)
def test_location_update(self):
"""Test location can be updated."""
update = InventoryUpdate(location="Warehouse B")
assert update.location == "Warehouse B"
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryReserveSchema:
"""Test InventoryReserve schema validation."""
def test_valid_reservation(self):
"""Test valid inventory reservation."""
reservation = InventoryReserve(
product_id=1,
location="Warehouse A",
quantity=10,
)
assert reservation.product_id == 1
assert reservation.quantity == 10
def test_quantity_must_be_positive(self):
"""Test reservation quantity must be > 0."""
with pytest.raises(ValidationError) as exc_info:
InventoryReserve(
product_id=1,
location="Warehouse A",
quantity=0,
)
assert "quantity" in str(exc_info.value).lower()
def test_negative_quantity_invalid(self):
"""Test negative reservation quantity is invalid."""
with pytest.raises(ValidationError):
InventoryReserve(
product_id=1,
location="Warehouse A",
quantity=-5,
)
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryResponseSchema:
"""Test InventoryResponse schema."""
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
data = {
"id": 1,
"product_id": 1,
"store_id": 1,
"location": "Warehouse A",
"quantity": 100,
"reserved_quantity": 20,
"gtin": "1234567890123",
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = InventoryResponse(**data)
assert response.id == 1
assert response.quantity == 100
assert response.reserved_quantity == 20
def test_available_quantity_property(self):
"""Test available_quantity calculated property."""
from datetime import datetime
data = {
"id": 1,
"product_id": 1,
"store_id": 1,
"location": "Warehouse A",
"quantity": 100,
"reserved_quantity": 30,
"gtin": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = InventoryResponse(**data)
assert response.available_quantity == 70
def test_available_quantity_never_negative(self):
"""Test available_quantity is never negative."""
from datetime import datetime
data = {
"id": 1,
"product_id": 1,
"store_id": 1,
"location": "Warehouse A",
"quantity": 10,
"reserved_quantity": 50, # Over-reserved
"gtin": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
}
response = InventoryResponse(**data)
assert response.available_quantity == 0
@pytest.mark.unit
@pytest.mark.schema
class TestInventoryLocationResponseSchema:
"""Test InventoryLocationResponse schema."""
def test_valid_location_response(self):
"""Test valid location response."""
location = InventoryLocationResponse(
location="Warehouse A",
quantity=100,
reserved_quantity=20,
available_quantity=80,
)
assert location.location == "Warehouse A"
assert location.quantity == 100
assert location.available_quantity == 80
@pytest.mark.unit
@pytest.mark.schema
class TestProductInventorySummarySchema:
"""Test ProductInventorySummary schema."""
def test_valid_summary(self):
"""Test valid inventory summary."""
summary = ProductInventorySummary(
product_id=1,
store_id=1,
product_sku="SKU-001",
product_title="Test Product",
total_quantity=200,
total_reserved=50,
total_available=150,
locations=[
InventoryLocationResponse(
location="Warehouse A",
quantity=100,
reserved_quantity=25,
available_quantity=75,
),
InventoryLocationResponse(
location="Warehouse B",
quantity=100,
reserved_quantity=25,
available_quantity=75,
),
],
)
assert summary.product_id == 1
assert summary.total_quantity == 200
assert len(summary.locations) == 2
def test_empty_locations(self):
"""Test summary with no locations."""
summary = ProductInventorySummary(
product_id=1,
store_id=1,
product_sku=None,
product_title="Test Product",
total_quantity=0,
total_reserved=0,
total_available=0,
locations=[],
)
assert summary.locations == []