diff --git a/app/modules/loyalty/config.py b/app/modules/loyalty/config.py index d8297eb3..4e34b735 100644 --- a/app/modules/loyalty/config.py +++ b/app/modules/loyalty/config.py @@ -29,10 +29,6 @@ class ModuleConfig(BaseSettings): # Points configuration default_points_per_euro: int = 10 # 10 points per euro spent - # Google Wallet - google_issuer_id: str | None = None - google_service_account_json: str | None = None # Path to JSON file - # Apple Wallet apple_pass_type_id: str | None = None apple_team_id: str | None = None diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 1b075432..cf3cef55 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -478,10 +478,14 @@ def list_store_transactions( db, merchant_id, skip=skip, limit=limit ) - return TransactionListResponse( - transactions=[TransactionResponse.model_validate(t) for t in transactions], - total=total, - ) + tx_responses = [] + for t in transactions: + tx = TransactionResponse.model_validate(t) + if t.card and t.card.customer: + tx.customer_name = t.card.customer.full_name + tx_responses.append(tx) + + return TransactionListResponse(transactions=tx_responses, total=total) @store_router.post("/cards/enroll", response_model=CardResponse, status_code=201) diff --git a/app/modules/loyalty/schemas/card.py b/app/modules/loyalty/schemas/card.py index 684c8aa2..6bfb51e4 100644 --- a/app/modules/loyalty/schemas/card.py +++ b/app/modules/loyalty/schemas/card.py @@ -175,6 +175,9 @@ class TransactionResponse(BaseModel): reward_description: str | None = None notes: str | None = None + # Customer + customer_name: str | None = None + # Staff staff_name: str | None = None diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 67f8288c..b0e677fb 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -655,7 +655,10 @@ class CardService: query = ( db.query(LoyaltyTransaction) .join(LoyaltyCard, LoyaltyTransaction.card_id == LoyaltyCard.id) - .options(joinedload(LoyaltyTransaction.store)) + .options( + joinedload(LoyaltyTransaction.store), + joinedload(LoyaltyTransaction.card).joinedload(LoyaltyCard.customer), + ) .filter(LoyaltyCard.merchant_id == merchant_id) ) if store_id: diff --git a/app/modules/loyalty/services/google_wallet_service.py b/app/modules/loyalty/services/google_wallet_service.py index bc946059..67ea1d2c 100644 --- a/app/modules/loyalty/services/google_wallet_service.py +++ b/app/modules/loyalty/services/google_wallet_service.py @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy.orm import Session -from app.modules.loyalty.config import config +from app.core.config import settings from app.modules.loyalty.exceptions import ( GoogleWalletNotConfiguredException, WalletIntegrationException, @@ -35,14 +35,14 @@ class GoogleWalletService: @property def is_configured(self) -> bool: """Check if Google Wallet is configured.""" - return bool(config.google_issuer_id and config.google_service_account_json) + return bool(settings.loyalty_google_issuer_id and settings.loyalty_google_service_account_json) def _get_credentials(self): """Get Google service account credentials.""" if self._credentials: return self._credentials - if not config.google_service_account_json: + if not settings.loyalty_google_service_account_json: raise GoogleWalletNotConfiguredException() try: @@ -51,7 +51,7 @@ class GoogleWalletService: scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"] self._credentials = service_account.Credentials.from_service_account_file( - config.google_service_account_json, + settings.loyalty_google_service_account_json, scopes=scopes, ) return self._credentials @@ -92,7 +92,7 @@ class GoogleWalletService: if not self.is_configured: raise GoogleWalletNotConfiguredException() - issuer_id = config.google_issuer_id + issuer_id = settings.loyalty_google_issuer_id class_id = f"{issuer_id}.loyalty_program_{program.id}" class_data = { @@ -203,7 +203,7 @@ class GoogleWalletService: # Create class first self.create_class(db, program) - issuer_id = config.google_issuer_id + issuer_id = settings.loyalty_google_issuer_id object_id = f"{issuer_id}.loyalty_card_{card.id}" object_data = self._build_object_data(card, object_id) diff --git a/app/modules/loyalty/tests/unit/test_wallet_service.py b/app/modules/loyalty/tests/unit/test_wallet_service.py index dde1a219..0f435d88 100644 --- a/app/modules/loyalty/tests/unit/test_wallet_service.py +++ b/app/modules/loyalty/tests/unit/test_wallet_service.py @@ -1,8 +1,123 @@ -"""Unit tests for WalletService.""" +# app/modules/loyalty/tests/unit/test_wallet_service.py +""" +Unit tests for WalletService — wallet object creation during enrollment. + +Tests that: +- Google Wallet objects are created and DB fields populated on enrollment +- Apple Wallet serial numbers are set when apple_pass_type_id is configured +- Wallet creation failures don't crash enrollment +- Wallet creation is skipped when not configured +""" + +import uuid +from unittest.mock import MagicMock, patch import pytest +from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram +from app.modules.loyalty.models.loyalty_program import LoyaltyType from app.modules.loyalty.services.wallet_service import WalletService +from app.modules.tenancy.models import Merchant + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def wt_merchant(db): + """Create a merchant for wallet tests.""" + merchant = Merchant( + name=f"Wallet Test Merchant {uuid.uuid4().hex[:8]}", + contact_email=f"wallet_{uuid.uuid4().hex[:8]}@test.com", + is_active=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def wt_program(db, wt_merchant): + """Create a loyalty program for wallet tests.""" + program = LoyaltyProgram( + merchant_id=wt_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=10, + welcome_bonus_points=0, + minimum_redemption_points=100, + cooldown_minutes=0, + max_daily_stamps=5, + require_staff_pin=False, + card_name="Wallet Test Rewards", + card_color="#4F46E5", + is_active=True, + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +@pytest.fixture +def wt_program_with_apple(db, wt_merchant): + """Create a loyalty program with Apple Wallet configured.""" + program = LoyaltyProgram( + merchant_id=wt_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=10, + welcome_bonus_points=0, + minimum_redemption_points=100, + cooldown_minutes=0, + max_daily_stamps=5, + require_staff_pin=False, + card_name="Apple Wallet Test", + card_color="#000000", + apple_pass_type_id="pass.com.test.loyalty", + is_active=True, + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +@pytest.fixture +def wt_card(db, wt_program): + """Create a loyalty card for wallet tests.""" + card = LoyaltyCard( + merchant_id=wt_program.merchant_id, + program_id=wt_program.id, + customer_id=None, + card_number=f"WT-{uuid.uuid4().hex[:8].upper()}", + is_active=True, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +@pytest.fixture +def wt_card_apple(db, wt_program_with_apple): + """Create a loyalty card for Apple Wallet tests.""" + card = LoyaltyCard( + merchant_id=wt_program_with_apple.merchant_id, + program_id=wt_program_with_apple.id, + customer_id=None, + card_number=f"WTA-{uuid.uuid4().hex[:8].upper()}", + is_active=True, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +# ============================================================================ +# WalletService instantiation +# ============================================================================ @pytest.mark.unit @@ -16,3 +131,303 @@ class TestWalletService: def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None + + +# ============================================================================ +# Google Wallet object creation +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestCreateWalletObjectsGoogle: + """Tests for Google Wallet object creation during enrollment.""" + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_object_created_when_configured( + self, mock_gw, db, wt_card + ): + """Google Wallet object is created when service is configured.""" + mock_gw.is_configured = True + mock_gw.create_object.return_value = "test_object_id" + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is True + mock_gw.create_object.assert_called_once_with(db, wt_card) + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_skipped_when_not_configured( + self, mock_gw, db, wt_card + ): + """Google Wallet is skipped when not configured.""" + mock_gw.is_configured = False + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is False + mock_gw.create_object.assert_not_called() + + @patch("app.modules.loyalty.services.wallet_service.google_wallet_service") + def test_google_wallet_failure_does_not_crash( + self, mock_gw, db, wt_card + ): + """Enrollment continues even if Google Wallet creation fails.""" + mock_gw.is_configured = True + mock_gw.create_object.side_effect = Exception("Google API error") + + service = WalletService() + results = service.create_wallet_objects(db, wt_card) + + assert results["google_wallet"] is False + # Card should still be valid + db.refresh(wt_card) + assert wt_card.is_active is True + + +# ============================================================================ +# Apple Wallet serial number setup +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestCreateWalletObjectsApple: + """Tests for Apple Wallet serial number assignment during enrollment.""" + + def test_apple_serial_number_set_when_configured(self, db, wt_card_apple): + """Apple serial number is set when program has apple_pass_type_id.""" + assert wt_card_apple.apple_serial_number is None + + service = WalletService() + with patch( + "app.modules.loyalty.services.wallet_service.google_wallet_service" + ) as mock_gw: + mock_gw.is_configured = False + results = service.create_wallet_objects(db, wt_card_apple) + + assert results["apple_wallet"] is True + db.refresh(wt_card_apple) + assert wt_card_apple.apple_serial_number is not None + assert wt_card_apple.apple_serial_number.startswith(f"card_{wt_card_apple.id}_") + + def test_apple_serial_number_not_set_when_unconfigured(self, db, wt_card): + """Apple serial number not set when program lacks apple_pass_type_id.""" + service = WalletService() + with patch( + "app.modules.loyalty.services.wallet_service.google_wallet_service" + ) as mock_gw: + mock_gw.is_configured = False + results = service.create_wallet_objects(db, wt_card) + + assert results["apple_wallet"] is False + db.refresh(wt_card) + assert wt_card.apple_serial_number is None + + +# ============================================================================ +# GoogleWalletService.create_object — DB field population +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestGoogleWalletCreateObject: + """Tests that GoogleWalletService.create_object populates DB fields correctly.""" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_object_sets_google_object_id(self, mock_settings, db, wt_card, wt_program): + """create_object sets card.google_object_id in the database.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + # Pre-set class_id on program so create_class isn't called + wt_program.google_class_id = "1234567890.loyalty_program_1" + db.commit() + + service = GoogleWalletService() + service._credentials = MagicMock() # Skip credential loading + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + service._http_client = mock_http + + result = service.create_object(db, wt_card) + + expected_object_id = f"1234567890.loyalty_card_{wt_card.id}" + assert result == expected_object_id + + db.refresh(wt_card) + assert wt_card.google_object_id == expected_object_id + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_object_handles_409_conflict(self, mock_settings, db, wt_card, wt_program): + """create_object handles 409 (already exists) by still setting the object_id.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + wt_program.google_class_id = "1234567890.loyalty_program_1" + db.commit() + + service = GoogleWalletService() + service._credentials = MagicMock() + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 409 # Already exists + mock_http.post.return_value = mock_response + service._http_client = mock_http + + service.create_object(db, wt_card) + + db.refresh(wt_card) + assert wt_card.google_object_id == f"1234567890.loyalty_card_{wt_card.id}" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_create_class_sets_google_class_id(self, mock_settings, db, wt_program): + """create_class sets program.google_class_id in the database.""" + from app.modules.loyalty.services.google_wallet_service import ( + GoogleWalletService, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + assert wt_program.google_class_id is None + + service = GoogleWalletService() + service._credentials = MagicMock() + + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + service._http_client = mock_http + + result = service.create_class(db, wt_program) + + expected_class_id = f"1234567890.loyalty_program_{wt_program.id}" + assert result == expected_class_id + + db.refresh(wt_program) + assert wt_program.google_class_id == expected_class_id + + +# ============================================================================ +# End-to-end: enrollment → wallet creation +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestEnrollmentWalletCreation: + """Tests that enrollment triggers wallet object creation and populates DB fields.""" + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_enrollment_creates_google_wallet_object( + self, mock_settings, db, wt_program, wt_merchant + ): + """Full enrollment flow creates Google Wallet class + object in DB.""" + from app.modules.loyalty.services.google_wallet_service import ( + google_wallet_service, + ) + + mock_settings.loyalty_google_issuer_id = "1234567890" + mock_settings.loyalty_google_service_account_json = "/fake/path.json" + + # Mock the HTTP client to simulate Google API success + mock_http = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 201 + mock_http.post.return_value = mock_response + google_wallet_service._http_client = mock_http + google_wallet_service._credentials = MagicMock() + + # Create a test customer + from app.modules.customers.models import Customer + customer = Customer( + email=f"wallet_test_{uuid.uuid4().hex[:8]}@test.com", + first_name="Wallet", + last_name="Test", + is_active=True, + ) + db.add(customer) + db.commit() + + # Enroll via card_service + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer(db, customer.id, wt_merchant.id) + + # Verify wallet DB fields are populated + db.refresh(card) + assert card.google_object_id is not None + assert card.google_object_id == f"1234567890.loyalty_card_{card.id}" + + # Verify program class_id is set + db.refresh(wt_program) + assert wt_program.google_class_id is not None + assert wt_program.google_class_id == f"1234567890.loyalty_program_{wt_program.id}" + + # Clean up singleton state + google_wallet_service._http_client = None + google_wallet_service._credentials = None + + def test_enrollment_succeeds_without_wallet_config(self, db, wt_program, wt_merchant): + """Enrollment works even when Google Wallet is not configured.""" + from app.modules.customers.models import Customer + customer = Customer( + email=f"no_wallet_{uuid.uuid4().hex[:8]}@test.com", + first_name="No", + last_name="Wallet", + is_active=True, + ) + db.add(customer) + db.commit() + + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer(db, customer.id, wt_merchant.id) + + db.refresh(card) + assert card.id is not None + assert card.is_active is True + # Wallet fields should be None since not configured + assert card.google_object_id is None + + @patch("app.modules.loyalty.services.google_wallet_service.settings") + def test_enrollment_with_apple_wallet_sets_serial( + self, mock_settings, db, wt_program_with_apple + ): + """Enrollment sets apple_serial_number when program has apple_pass_type_id.""" + mock_settings.loyalty_google_issuer_id = None + mock_settings.loyalty_google_service_account_json = None + + from app.modules.customers.models import Customer + customer = Customer( + email=f"apple_test_{uuid.uuid4().hex[:8]}@test.com", + first_name="Apple", + last_name="Test", + is_active=True, + ) + db.add(customer) + db.commit() + + from app.modules.loyalty.services.card_service import card_service + card = card_service.enroll_customer( + db, customer.id, wt_program_with_apple.merchant_id + ) + + db.refresh(card) + assert card.apple_serial_number is not None + assert card.apple_serial_number.startswith(f"card_{card.id}_")