diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 60c23fe0..d1ef845f 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -23,6 +23,13 @@ def _get_admin_router(): return admin_router +def _get_merchant_router(): + """Lazy import of merchant router to avoid circular imports.""" + from app.modules.loyalty.routes.api.merchant import router as merchant_router + + return merchant_router + + def _get_store_router(): """Lazy import of store router to avoid circular imports.""" from app.modules.loyalty.routes.api.store import store_router @@ -119,6 +126,7 @@ loyalty_module = ModuleDefinition( ], FrontendType.MERCHANT: [ "loyalty-overview", # Merchant loyalty overview + "loyalty-settings", # Merchant loyalty settings ], }, # New module-driven menu definitions @@ -192,6 +200,13 @@ loyalty_module = ModuleDefinition( route="/merchants/loyalty/overview", order=10, ), + MenuItemDefinition( + id="loyalty-settings", + label_key="loyalty.menu.settings", + icon="cog", + route="/merchants/loyalty/settings", + order=20, + ), ], ), ], @@ -253,6 +268,7 @@ def get_loyalty_module_with_routers() -> ModuleDefinition: during module initialization. """ loyalty_module.admin_router = _get_admin_router() + loyalty_module.merchant_router = _get_merchant_router() loyalty_module.store_router = _get_store_router() loyalty_module.platform_router = _get_platform_router() loyalty_module.storefront_router = _get_storefront_router() diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index b5463020..0d6c10d2 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Kundenkarten", - "statistics": "Statistiken" + "statistics": "Statistiken", + "overview": "Übersicht", + "settings": "Einstellungen" } } diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index a0b40756..b288b40b 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Customer Cards", - "statistics": "Statistics" + "statistics": "Statistics", + "overview": "Overview", + "settings": "Settings" } } diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index df018e27..0f7de3f3 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -77,6 +77,8 @@ "dashboard": "Tableau de bord", "terminal": "Terminal", "customer_cards": "Cartes clients", - "statistics": "Statistiques" + "statistics": "Statistiques", + "overview": "Aperçu", + "settings": "Paramètres" } } diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index 954c69f5..cf3d56a9 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -77,6 +77,8 @@ "dashboard": "Dashboard", "terminal": "Terminal", "customer_cards": "Clientekaarten", - "statistics": "Statistiken" + "statistics": "Statistiken", + "overview": "Iwwersiicht", + "settings": "Astellungen" } } diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 9cd4425e..d256a222 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -20,9 +20,11 @@ from app.modules.loyalty.schemas import ( MerchantSettingsResponse, MerchantSettingsUpdate, MerchantStatsResponse, + ProgramCreate, ProgramListResponse, ProgramResponse, ProgramStatsResponse, + ProgramUpdate, ) from app.modules.loyalty.services import program_service from app.modules.tenancy.models import User # API-007 @@ -107,6 +109,93 @@ def get_program_stats( return ProgramStatsResponse(**stats) +@admin_router.post( + "/merchants/{merchant_id}/program", response_model=ProgramResponse, status_code=201 +) +def create_program_for_merchant( + data: ProgramCreate, + merchant_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Create a loyalty program for a merchant (admin override).""" + program = program_service.create_program(db, merchant_id, data) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin created loyalty program for merchant {merchant_id}") + return response + + +@admin_router.patch("/programs/{program_id}", response_model=ProgramResponse) +def update_program( + data: ProgramUpdate, + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Update a loyalty program (admin override).""" + program = program_service.update_program(db, program_id, data) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin updated loyalty program {program_id}") + return response + + +@admin_router.delete("/programs/{program_id}", status_code=204) +def delete_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Delete a loyalty program (admin override).""" + program_service.delete_program(db, program_id) + logger.info(f"Admin deleted loyalty program {program_id}") + + +@admin_router.post("/programs/{program_id}/activate", response_model=ProgramResponse) +def activate_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Activate a loyalty program.""" + program = program_service.activate_program(db, program_id) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin activated loyalty program {program_id}") + return response + + +@admin_router.post("/programs/{program_id}/deactivate", response_model=ProgramResponse) +def deactivate_program( + program_id: int = Path(..., gt=0), + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Deactivate a loyalty program.""" + program = program_service.deactivate_program(db, program_id) + + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + + logger.info(f"Admin deactivated loyalty program {program_id}") + return response + + # ============================================================================= # Merchant Management # ============================================================================= diff --git a/app/modules/loyalty/routes/api/merchant.py b/app/modules/loyalty/routes/api/merchant.py new file mode 100644 index 00000000..5030d2c0 --- /dev/null +++ b/app/modules/loyalty/routes/api/merchant.py @@ -0,0 +1,98 @@ +# app/modules/loyalty/routes/api/merchant.py +""" +Loyalty module merchant routes. + +Merchant portal endpoints for full program CRUD: +- Get merchant's loyalty program +- Create a loyalty program +- Update the loyalty program +- Delete the loyalty program + +Authentication: Authorization header (API-only, no cookies for CSRF safety). +The user must own at least one active merchant (validated by +get_merchant_for_current_user). + +Auto-discovered by the route system (merchant.py in routes/api/ triggers +registration under /api/v1/merchants/loyalty/*). +""" + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_merchant_for_current_user +from app.core.database import get_db +from app.modules.loyalty.schemas import ( + ProgramCreate, + ProgramResponse, + ProgramUpdate, +) +from app.modules.loyalty.services import program_service +from app.modules.tenancy.models import Merchant + +logger = logging.getLogger(__name__) + +ROUTE_CONFIG = { + "prefix": "/loyalty", +} + +router = APIRouter() + + +def _build_program_response(program) -> ProgramResponse: + """Build a ProgramResponse from a program ORM object.""" + response = ProgramResponse.model_validate(program) + response.is_stamps_enabled = program.is_stamps_enabled + response.is_points_enabled = program.is_points_enabled + response.display_name = program.display_name + return response + + +# ============================================================================= +# Program CRUD +# ============================================================================= + + +@router.get("/program", response_model=ProgramResponse) +def get_program( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Get the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + return _build_program_response(program) + + +@router.post("/program", response_model=ProgramResponse, status_code=201) +def create_program( + data: ProgramCreate, + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Create a loyalty program for the merchant.""" + program = program_service.create_program(db, merchant.id, data) + return _build_program_response(program) + + +@router.patch("/program", response_model=ProgramResponse) +def update_program( + data: ProgramUpdate, + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Update the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + program = program_service.update_program(db, program.id, data) + return _build_program_response(program) + + +@router.delete("/program", status_code=204) +def delete_program( + merchant: Merchant = Depends(get_merchant_for_current_user), + db: Session = Depends(get_db), +): + """Delete the merchant's loyalty program.""" + program = program_service.require_program_by_merchant(db, merchant.id) + program_service.delete_program(db, program.id) + logger.info(f"Merchant {merchant.id} ({merchant.name}) deleted loyalty program") diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index 127ca205..f5ebced2 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -40,10 +40,8 @@ from app.modules.loyalty.schemas import ( PointsRedeemResponse, PointsVoidRequest, PointsVoidResponse, - ProgramCreate, ProgramResponse, ProgramStatsResponse, - ProgramUpdate, StampRedeemRequest, StampRedeemResponse, StampRequest, @@ -106,46 +104,6 @@ def get_program( return response -@store_router.post("/program", response_model=ProgramResponse, status_code=201) -def create_program( - data: ProgramCreate, - current_user: User = Depends(get_current_store_api), - db: Session = Depends(get_db), -): - """Create a loyalty program for the merchant.""" - store_id = current_user.token_store_id - merchant_id = get_store_merchant_id(db, store_id) - - program = program_service.create_program(db, merchant_id, data) - - response = ProgramResponse.model_validate(program) - response.is_stamps_enabled = program.is_stamps_enabled - response.is_points_enabled = program.is_points_enabled - response.display_name = program.display_name - - return response - - -@store_router.patch("/program", response_model=ProgramResponse) -def update_program( - data: ProgramUpdate, - current_user: User = Depends(get_current_store_api), - db: Session = Depends(get_db), -): - """Update the merchant's loyalty program.""" - store_id = current_user.token_store_id - - program = program_service.require_program_by_store(db, store_id) - program = program_service.update_program(db, program.id, data) - - response = ProgramResponse.model_validate(program) - response.is_stamps_enabled = program.is_stamps_enabled - response.is_points_enabled = program.is_points_enabled - response.display_name = program.display_name - - return response - - @store_router.get("/stats", response_model=ProgramStatsResponse) def get_stats( current_user: User = Depends(get_current_store_api), diff --git a/app/modules/loyalty/routes/pages/merchant.py b/app/modules/loyalty/routes/pages/merchant.py index 7207e1c3..416ad4b9 100644 --- a/app/modules/loyalty/routes/pages/merchant.py +++ b/app/modules/loyalty/routes/pages/merchant.py @@ -98,3 +98,32 @@ async def merchant_loyalty_overview( "loyalty/merchant/overview.html", context, ) + + +# ============================================================================ +# LOYALTY SETTINGS +# ============================================================================ + + +@router.get("/settings", response_class=HTMLResponse, include_in_schema=False) +async def merchant_loyalty_settings( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + merchant: Merchant = Depends(get_merchant_for_current_user_page), + db: Session = Depends(get_db), +): + """ + Render merchant loyalty settings page. + + Allows merchant to create, configure, and manage their loyalty program. + """ + context = _get_merchant_context( + request, + db, + current_user, + merchant_id=merchant.id, + ) + return templates.TemplateResponse( + "loyalty/merchant/settings.html", + context, + ) diff --git a/app/modules/loyalty/routes/pages/store.py b/app/modules/loyalty/routes/pages/store.py index be280c2e..af0fec08 100644 --- a/app/modules/loyalty/routes/pages/store.py +++ b/app/modules/loyalty/routes/pages/store.py @@ -178,32 +178,6 @@ async def store_loyalty_card_detail( ) -# ============================================================================ -# PROGRAM SETTINGS -# ============================================================================ - - -@router.get( - "/{store_code}/loyalty/settings", - response_class=HTMLResponse, - include_in_schema=False, -) -async def store_loyalty_settings( - request: Request, - store_code: str = Path(..., description="Store code"), - current_user: User = Depends(get_current_store_from_cookie_or_header), - db: Session = Depends(get_db), -): - """ - Render loyalty program settings page. - Allows store to configure points rate, rewards, branding, etc. - """ - return templates.TemplateResponse( - "loyalty/store/settings.html", - get_store_context(request, db, current_user, store_code), - ) - - # ============================================================================ # STATS DASHBOARD # ============================================================================ diff --git a/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js b/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js index f9fff665..e785b580 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js +++ b/app/modules/loyalty/static/admin/js/loyalty-merchant-detail.js @@ -36,6 +36,7 @@ function adminLoyaltyMerchantDetail() { // State loading: false, error: null, + showDeleteModal: false, // Initialize async init() { @@ -176,6 +177,67 @@ function adminLoyaltyMerchantDetail() { } }, + // Create a default program for this merchant + async createProgram() { + try { + const data = { + loyalty_type: 'points', + points_per_euro: 1, + welcome_bonus_points: 0, + minimum_redemption_points: 100, + card_name: this.merchant?.name ? this.merchant.name + ' Loyalty' : 'Loyalty Program', + is_active: true + }; + const response = await apiClient.post(`/admin/loyalty/merchants/${this.merchantId}/program`, data); + this.program = response; + Utils.showToast('Loyalty program created', 'success'); + loyaltyMerchantDetailLog.info('Program created for merchant', this.merchantId); + // Reload stats + await this.loadStats(); + } catch (error) { + Utils.showToast(`Failed to create program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error('Failed to create program:', error); + } + }, + + // Toggle program active/inactive + async toggleActive() { + if (!this.program) return; + const action = this.program.is_active ? 'deactivate' : 'activate'; + try { + const response = await apiClient.post(`/admin/loyalty/programs/${this.program.id}/${action}`); + this.program.is_active = response.is_active; + Utils.showToast(`Program ${action}d successfully`, 'success'); + loyaltyMerchantDetailLog.info(`Program ${action}d`); + } catch (error) { + Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error(`Failed to ${action} program:`, error); + } + }, + + // Show delete confirmation + confirmDeleteProgram() { + this.showDeleteModal = true; + }, + + // Delete the program + async deleteProgram() { + if (!this.program) return; + try { + await apiClient.delete(`/admin/loyalty/programs/${this.program.id}`); + this.program = null; + this.showDeleteModal = false; + Utils.showToast('Loyalty program deleted', 'success'); + loyaltyMerchantDetailLog.info('Program deleted'); + // Reload stats + await this.loadStats(); + } catch (error) { + Utils.showToast(`Failed to delete program: ${error.message}`, 'error'); + loyaltyMerchantDetailLog.error('Failed to delete program:', error); + this.showDeleteModal = false; + } + }, + // Format date for display formatDate(dateString) { if (!dateString) return 'N/A'; diff --git a/app/modules/loyalty/static/admin/js/loyalty-programs.js b/app/modules/loyalty/static/admin/js/loyalty-programs.js index dc7feca0..d13e0f3c 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-programs.js +++ b/app/modules/loyalty/static/admin/js/loyalty-programs.js @@ -232,6 +232,20 @@ function adminLoyaltyPrograms() { } }, + // Toggle program active/inactive + async toggleProgramActive(program) { + const action = program.is_active ? 'deactivate' : 'activate'; + try { + const response = await apiClient.post(`/admin/loyalty/programs/${program.id}/${action}`); + program.is_active = response.is_active; + Utils.showToast(`Program ${action}d successfully`, 'success'); + loyaltyProgramsLog.info(`Program ${program.id} ${action}d`); + } catch (error) { + Utils.showToast(`Failed to ${action} program: ${error.message}`, 'error'); + loyaltyProgramsLog.error(`Failed to ${action} program:`, error); + } + }, + // Format date for display formatDate(dateString) { if (!dateString) return 'N/A'; diff --git a/app/modules/loyalty/static/store/js/loyalty-settings.js b/app/modules/loyalty/static/merchant/js/loyalty-settings.js similarity index 69% rename from app/modules/loyalty/static/store/js/loyalty-settings.js rename to app/modules/loyalty/static/merchant/js/loyalty-settings.js index 46e86382..4d600f02 100644 --- a/app/modules/loyalty/static/store/js/loyalty-settings.js +++ b/app/modules/loyalty/static/merchant/js/loyalty-settings.js @@ -1,9 +1,9 @@ -// app/modules/loyalty/static/store/js/loyalty-settings.js +// app/modules/loyalty/static/merchant/js/loyalty-settings.js // noqa: js-006 - async init pattern is safe, loadData has try/catch const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings'); -function storeLoyaltySettings() { +function merchantLoyaltySettings() { return { ...data(), currentPage: 'loyalty-settings', @@ -22,22 +22,18 @@ function storeLoyaltySettings() { loading: false, saving: false, + deleting: false, error: null, isNewProgram: false, + showDeleteModal: false, async init() { - loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZING ==='); - if (window._loyaltySettingsInitialized) return; - window._loyaltySettingsInitialized = true; - - // IMPORTANT: Call parent init first to set storeCode from URL - const parentInit = data().init; - if (parentInit) { - await parentInit.call(this); - } + loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZING ==='); + if (window._merchantLoyaltySettingsInitialized) return; + window._merchantLoyaltySettingsInitialized = true; await this.loadSettings(); - loyaltySettingsLog.info('=== LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); + loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ==='); }, async loadSettings() { @@ -45,7 +41,7 @@ function storeLoyaltySettings() { this.error = null; try { - const response = await apiClient.get('/store/loyalty/program'); + const response = await apiClient.get('/merchants/loyalty/program'); if (response) { this.settings = { loyalty_type: response.loyalty_type || 'points', @@ -86,10 +82,10 @@ function storeLoyaltySettings() { let response; if (this.isNewProgram) { - response = await apiClient.post('/store/loyalty/program', this.settings); + response = await apiClient.post('/merchants/loyalty/program', this.settings); this.isNewProgram = false; } else { - response = await apiClient.patch('/store/loyalty/program', this.settings); + response = await apiClient.patch('/merchants/loyalty/program', this.settings); } Utils.showToast('Settings saved successfully', 'success'); @@ -102,6 +98,28 @@ function storeLoyaltySettings() { } }, + confirmDelete() { + this.showDeleteModal = true; + }, + + async deleteProgram() { + this.deleting = true; + + try { + await apiClient.delete('/merchants/loyalty/program'); + Utils.showToast('Loyalty program deleted', 'success'); + loyaltySettingsLog.info('Program deleted'); + // Redirect to overview + window.location.href = '/merchants/loyalty/overview'; + } catch (error) { + Utils.showToast(`Failed to delete: ${error.message}`, 'error'); + loyaltySettingsLog.error('Delete failed:', error); + } finally { + this.deleting = false; + this.showDeleteModal = false; + } + }, + addReward() { this.settings.points_rewards.push({ id: `reward_${Date.now()}`, @@ -121,4 +139,4 @@ function storeLoyaltySettings() { if (!window.LogConfig.loggers.loyaltySettings) { window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings'); } -loyaltySettingsLog.info('Loyalty settings module loaded'); +loyaltySettingsLog.info('Merchant loyalty settings module loaded'); diff --git a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html index 8773168a..026b5e2d 100644 --- a/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html +++ b/app/modules/loyalty/templates/loyalty/admin/merchant-detail.html @@ -105,10 +105,27 @@
-

- - Program Configuration -

+
+

+ + Program Configuration +

+
+ + +
+

Program Name

@@ -142,11 +159,39 @@
-
- -
-

No Loyalty Program

-

This merchant has not set up a loyalty program yet. Stores can set up the program from their dashboard.

+
+
+ +
+

No Loyalty Program

+

This merchant has not set up a loyalty program yet.

+
+
+ +
+
+ + +
+
+

Delete Loyalty Program

+

+ This will permanently delete the loyalty program and all associated data. This action cannot be undone. +

+
+ +
diff --git a/app/modules/loyalty/templates/loyalty/admin/programs.html b/app/modules/loyalty/templates/loyalty/admin/programs.html index 2fbdf7dc..e0ebbbc4 100644 --- a/app/modules/loyalty/templates/loyalty/admin/programs.html +++ b/app/modules/loyalty/templates/loyalty/admin/programs.html @@ -217,14 +217,24 @@ - + - + + + +
diff --git a/app/modules/loyalty/templates/loyalty/merchant/overview.html b/app/modules/loyalty/templates/loyalty/merchant/overview.html index 52955381..3bc99f63 100644 --- a/app/modules/loyalty/templates/loyalty/merchant/overview.html +++ b/app/modules/loyalty/templates/loyalty/merchant/overview.html @@ -7,9 +7,18 @@
-
-

Loyalty Overview

-

Loyalty program statistics across all your stores.

+
+
+

Loyalty Overview

+

Loyalty program statistics across all your stores.

+
+
@@ -18,8 +27,13 @@

No Loyalty Program

- Your loyalty program hasn't been set up yet. Contact the platform administrator or set it up from your store dashboard. + Your loyalty program hasn't been set up yet. Create one to start rewarding your customers.

+ + + Create Program +
diff --git a/app/modules/loyalty/templates/loyalty/store/settings.html b/app/modules/loyalty/templates/loyalty/merchant/settings.html similarity index 80% rename from app/modules/loyalty/templates/loyalty/store/settings.html rename to app/modules/loyalty/templates/loyalty/merchant/settings.html index d723f9fd..09fae302 100644 --- a/app/modules/loyalty/templates/loyalty/store/settings.html +++ b/app/modules/loyalty/templates/loyalty/merchant/settings.html @@ -1,11 +1,11 @@ -{# app/modules/loyalty/templates/loyalty/store/settings.html #} -{% extends "store/base.html" %} +{# app/modules/loyalty/templates/loyalty/merchant/settings.html #} +{% extends "merchant/base.html" %} {% from 'shared/macros/headers.html' import page_header_flex %} {% from 'shared/macros/alerts.html' import loading_state, error_state %} {% block title %}Loyalty Settings{% endblock %} -{% block alpine_data %}storeLoyaltySettings(){% endblock %} +{% block alpine_data %}merchantLoyaltySettings(){% endblock %} {% block content %} {% call page_header_flex(title='Loyalty Program Settings', subtitle='Configure your loyalty program') %}{% endcall %} @@ -24,7 +24,7 @@
-

1 EUR = point(s)

@@ -142,17 +142,48 @@
-
+
+
+ +
+ + +
+
+

Delete Loyalty Program

+

+ This will permanently delete your loyalty program and all associated data (cards, transactions, rewards). + This action cannot be undone. +

+
+ + +
+
+
{% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/app/modules/loyalty/tests/conftest.py b/app/modules/loyalty/tests/conftest.py index 6a80c7ce..4f032a02 100644 --- a/app/modules/loyalty/tests/conftest.py +++ b/app/modules/loyalty/tests/conftest.py @@ -9,11 +9,14 @@ from datetime import UTC, datetime import pytest +from app.api.deps import get_current_merchant_api, get_merchant_for_current_user from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram from app.modules.loyalty.models.loyalty_program import LoyaltyType from app.modules.tenancy.models import Merchant, Platform, Store, User from app.modules.tenancy.models.store import StoreUser from app.modules.tenancy.models.store_platform import StorePlatform +from main import app +from models.schema.auth import UserContext @pytest.fixture @@ -166,6 +169,95 @@ def loyalty_store_setup(db, loyalty_platform): } +@pytest.fixture +def loyalty_merchant_setup(db, loyalty_platform): + """ + Merchant-only setup for loyalty integration tests (no program). + + Creates: User -> Merchant (no program yet). + Use this for testing program creation via merchant API. + """ + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"merchowner_{uid}@test.com", + username=f"merchowner_{uid}", + hashed_password=auth.hash_password("merchpass123"), + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(owner) + db.commit() + db.refresh(owner) + + merchant = Merchant( + name=f"Loyalty Merchant {uid}", + owner_user_id=owner.id, + contact_email=owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + return { + "owner": owner, + "merchant": merchant, + } + + +@pytest.fixture +def loyalty_merchant_headers(loyalty_store_setup): + """ + Override auth dependencies to return merchant/user for the merchant owner. + Uses the full loyalty_store_setup which includes a program. + """ + owner = loyalty_store_setup["owner"] + merchant = loyalty_store_setup["merchant"] + + user_context = UserContext( + id=owner.id, + email=owner.email, + username=owner.username, + role="merchant_owner", + is_active=True, + ) + + app.dependency_overrides[get_merchant_for_current_user] = lambda: merchant + app.dependency_overrides[get_current_merchant_api] = lambda: user_context + yield {"Authorization": "Bearer fake-token"} + app.dependency_overrides.pop(get_merchant_for_current_user, None) + app.dependency_overrides.pop(get_current_merchant_api, None) + + +@pytest.fixture +def loyalty_merchant_headers_no_program(loyalty_merchant_setup): + """ + Override auth dependencies for a merchant that has no program yet. + """ + owner = loyalty_merchant_setup["owner"] + merchant = loyalty_merchant_setup["merchant"] + + user_context = UserContext( + id=owner.id, + email=owner.email, + username=owner.username, + role="merchant_owner", + is_active=True, + ) + + app.dependency_overrides[get_merchant_for_current_user] = lambda: merchant + app.dependency_overrides[get_current_merchant_api] = lambda: user_context + yield {"Authorization": "Bearer fake-token"} + app.dependency_overrides.pop(get_merchant_for_current_user, None) + app.dependency_overrides.pop(get_current_merchant_api, None) + + @pytest.fixture def loyalty_store_headers(client, loyalty_store_setup): """ diff --git a/app/modules/loyalty/tests/integration/test_admin_api.py b/app/modules/loyalty/tests/integration/test_admin_api.py new file mode 100644 index 00000000..5a6dfd24 --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_admin_api.py @@ -0,0 +1,385 @@ +# app/modules/loyalty/tests/integration/test_admin_api.py +""" +Integration tests for admin loyalty CRUD API endpoints. + +Tests the admin program management endpoints at: + /api/v1/admin/loyalty/* + +Authentication: Uses super_admin_headers fixture (real JWT login). +""" + +import uuid +from datetime import UTC, datetime + +import pytest + +from app.modules.loyalty.models import LoyaltyProgram +from app.modules.loyalty.models.loyalty_program import LoyaltyType +from app.modules.tenancy.models import Merchant, User + +BASE = "/api/v1/admin/loyalty" + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def admin_merchant(db): + """Create a merchant for admin CRUD tests.""" + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"adminmerchowner_{uid}@test.com", + username=f"adminmerchowner_{uid}", + hashed_password=auth.hash_password("testpass123"), + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(owner) + db.commit() + db.refresh(owner) + + merchant = Merchant( + name=f"Admin Test Merchant {uid}", + owner_user_id=owner.id, + contact_email=owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + return merchant + + +@pytest.fixture +def admin_program(db, admin_merchant): + """Create a loyalty program for admin CRUD tests.""" + program = LoyaltyProgram( + merchant_id=admin_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=5, + welcome_bonus_points=25, + minimum_redemption_points=50, + minimum_purchase_cents=0, + cooldown_minutes=0, + max_daily_stamps=10, + require_staff_pin=False, + card_name="Admin Test Rewards", + card_color="#1E90FF", + is_active=True, + points_rewards=[ + {"id": "reward_1", "name": "5 EUR off", "points_required": 50, "is_active": True}, + ], + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +# ============================================================================ +# POST /merchants/{merchant_id}/program +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminCreateProgram: + """Tests for POST /api/v1/admin/loyalty/merchants/{merchant_id}/program.""" + + def test_create_program_for_merchant( + self, client, super_admin_headers, admin_merchant + ): + """Admin can create a program for any merchant.""" + response = client.post( + f"{BASE}/merchants/{admin_merchant.id}/program", + json={ + "loyalty_type": "points", + "points_per_euro": 8, + "card_name": "Admin Created", + "card_color": "#00FF00", + }, + headers=super_admin_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["merchant_id"] == admin_merchant.id + assert data["points_per_euro"] == 8 + assert data["card_name"] == "Admin Created" + + def test_create_program_with_stamps( + self, client, super_admin_headers, admin_merchant + ): + """Admin can create a stamps-type program.""" + response = client.post( + f"{BASE}/merchants/{admin_merchant.id}/program", + json={ + "loyalty_type": "stamps", + "stamps_target": 10, + "stamps_reward_description": "Free coffee", + }, + headers=super_admin_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["loyalty_type"] == "stamps" + assert data["stamps_target"] == 10 + + def test_create_program_requires_auth( + self, client, admin_merchant + ): + """Unauthenticated request is rejected.""" + response = client.post( + f"{BASE}/merchants/{admin_merchant.id}/program", + json={"loyalty_type": "points"}, + ) + assert response.status_code == 401 + + +# ============================================================================ +# PATCH /programs/{program_id} +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminUpdateProgram: + """Tests for PATCH /api/v1/admin/loyalty/programs/{program_id}.""" + + def test_update_program( + self, client, super_admin_headers, admin_program + ): + """Admin can update any program.""" + response = client.patch( + f"{BASE}/programs/{admin_program.id}", + json={ + "points_per_euro": 15, + "card_name": "Updated by Admin", + }, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["points_per_euro"] == 15 + assert data["card_name"] == "Updated by Admin" + + def test_update_program_partial( + self, client, super_admin_headers, admin_program + ): + """Partial update only changes specified fields.""" + response = client.patch( + f"{BASE}/programs/{admin_program.id}", + json={"card_name": "Only Name Changed"}, + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["card_name"] == "Only Name Changed" + assert data["points_per_euro"] == 5 # unchanged + + def test_update_nonexistent_program( + self, client, super_admin_headers + ): + """Updating non-existent program returns 404.""" + response = client.patch( + f"{BASE}/programs/999999", + json={"card_name": "Ghost"}, + headers=super_admin_headers, + ) + assert response.status_code == 404 + + +# ============================================================================ +# DELETE /programs/{program_id} +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminDeleteProgram: + """Tests for DELETE /api/v1/admin/loyalty/programs/{program_id}.""" + + def test_delete_program( + self, client, super_admin_headers, admin_program, db + ): + """Admin can delete any program.""" + program_id = admin_program.id + + response = client.delete( + f"{BASE}/programs/{program_id}", + headers=super_admin_headers, + ) + assert response.status_code == 204 + + # Verify deleted + deleted = db.get(LoyaltyProgram, program_id) + assert deleted is None + + def test_delete_nonexistent_program( + self, client, super_admin_headers + ): + """Deleting non-existent program returns 404.""" + response = client.delete( + f"{BASE}/programs/999999", + headers=super_admin_headers, + ) + assert response.status_code == 404 + + +# ============================================================================ +# POST /programs/{program_id}/activate +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminActivateProgram: + """Tests for POST /api/v1/admin/loyalty/programs/{program_id}/activate.""" + + def test_activate_inactive_program( + self, client, super_admin_headers, admin_program, db + ): + """Admin can activate an inactive program.""" + # First deactivate + admin_program.is_active = False + db.commit() + db.refresh(admin_program) + + response = client.post( + f"{BASE}/programs/{admin_program.id}/activate", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["is_active"] is True + + def test_activate_already_active_program( + self, client, super_admin_headers, admin_program + ): + """Activating an already active program succeeds (idempotent).""" + assert admin_program.is_active is True + + response = client.post( + f"{BASE}/programs/{admin_program.id}/activate", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is True + + def test_activate_nonexistent_program( + self, client, super_admin_headers + ): + """Activating non-existent program returns 404.""" + response = client.post( + f"{BASE}/programs/999999/activate", + headers=super_admin_headers, + ) + assert response.status_code == 404 + + +# ============================================================================ +# POST /programs/{program_id}/deactivate +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminDeactivateProgram: + """Tests for POST /api/v1/admin/loyalty/programs/{program_id}/deactivate.""" + + def test_deactivate_active_program( + self, client, super_admin_headers, admin_program + ): + """Admin can deactivate an active program.""" + response = client.post( + f"{BASE}/programs/{admin_program.id}/deactivate", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["is_active"] is False + + def test_deactivate_already_inactive_program( + self, client, super_admin_headers, admin_program, db + ): + """Deactivating an already inactive program succeeds (idempotent).""" + admin_program.is_active = False + db.commit() + + response = client.post( + f"{BASE}/programs/{admin_program.id}/deactivate", + headers=super_admin_headers, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + def test_deactivate_nonexistent_program( + self, client, super_admin_headers + ): + """Deactivating non-existent program returns 404.""" + response = client.post( + f"{BASE}/programs/999999/deactivate", + headers=super_admin_headers, + ) + assert response.status_code == 404 + + +# ============================================================================ +# Existing Admin Endpoints Still Work +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestAdminExistingEndpoints: + """Verify existing admin endpoints still work after CRUD additions.""" + + def test_list_programs( + self, client, super_admin_headers, admin_program + ): + """GET /programs returns list including created program.""" + response = client.get( + f"{BASE}/programs", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert "programs" in data + assert "total" in data + assert data["total"] >= 1 + + def test_get_program_by_id( + self, client, super_admin_headers, admin_program + ): + """GET /programs/{id} returns specific program.""" + response = client.get( + f"{BASE}/programs/{admin_program.id}", + headers=super_admin_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == admin_program.id + + def test_get_program_stats( + self, client, super_admin_headers, admin_program + ): + """GET /programs/{id}/stats returns statistics.""" + response = client.get( + f"{BASE}/programs/{admin_program.id}/stats", + headers=super_admin_headers, + ) + assert response.status_code == 200 diff --git a/app/modules/loyalty/tests/integration/test_merchant_api.py b/app/modules/loyalty/tests/integration/test_merchant_api.py new file mode 100644 index 00000000..7a0aa403 --- /dev/null +++ b/app/modules/loyalty/tests/integration/test_merchant_api.py @@ -0,0 +1,267 @@ +# app/modules/loyalty/tests/integration/test_merchant_api.py +""" +Integration tests for merchant loyalty API endpoints. + +Tests the merchant program CRUD endpoints at: + /api/v1/merchants/loyalty/* + +Authentication: Uses dependency overrides (merch_auth_headers pattern). +""" + +import pytest + +BASE = "/api/v1/merchants/loyalty" + + +# ============================================================================ +# GET /program +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantGetProgram: + """Tests for GET /api/v1/merchants/loyalty/program.""" + + def test_get_program_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Returns the merchant's loyalty program.""" + response = client.get( + f"{BASE}/program", headers=loyalty_merchant_headers + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == loyalty_store_setup["program"].id + assert data["merchant_id"] == loyalty_store_setup["merchant"].id + assert data["points_per_euro"] == 10 + assert data["is_active"] is True + + def test_get_program_includes_display_fields( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Response includes computed display fields.""" + response = client.get( + f"{BASE}/program", headers=loyalty_merchant_headers + ) + assert response.status_code == 200 + data = response.json() + assert "display_name" in data + assert "is_stamps_enabled" in data + assert "is_points_enabled" in data + + def test_get_program_not_found( + self, client, loyalty_merchant_headers_no_program + ): + """Returns 404 when merchant has no program.""" + response = client.get( + f"{BASE}/program", headers=loyalty_merchant_headers_no_program + ) + assert response.status_code == 404 + + +# ============================================================================ +# POST /program +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantCreateProgram: + """Tests for POST /api/v1/merchants/loyalty/program.""" + + def test_create_program_success( + self, client, loyalty_merchant_headers_no_program, loyalty_merchant_setup + ): + """Create a new loyalty program for the merchant.""" + response = client.post( + f"{BASE}/program", + json={ + "loyalty_type": "points", + "points_per_euro": 5, + "card_name": "My Rewards", + "card_color": "#FF5733", + }, + headers=loyalty_merchant_headers_no_program, + ) + assert response.status_code == 201 + data = response.json() + assert data["loyalty_type"] == "points" + assert data["points_per_euro"] == 5 + assert data["card_name"] == "My Rewards" + assert data["card_color"] == "#FF5733" + assert data["merchant_id"] == loyalty_merchant_setup["merchant"].id + + def test_create_program_with_rewards( + self, client, loyalty_merchant_headers_no_program + ): + """Create program with point rewards configured.""" + response = client.post( + f"{BASE}/program", + json={ + "loyalty_type": "points", + "points_per_euro": 10, + "points_rewards": [ + { + "id": "r1", + "name": "5 EUR off", + "points_required": 100, + "is_active": True, + }, + { + "id": "r2", + "name": "10 EUR off", + "points_required": 200, + "is_active": True, + }, + ], + }, + headers=loyalty_merchant_headers_no_program, + ) + assert response.status_code == 201 + data = response.json() + assert len(data["points_rewards"]) == 2 + assert data["points_rewards"][0]["name"] == "5 EUR off" + + def test_create_program_defaults( + self, client, loyalty_merchant_headers_no_program + ): + """Creating with minimal data uses sensible defaults.""" + response = client.post( + f"{BASE}/program", + json={"loyalty_type": "points"}, + headers=loyalty_merchant_headers_no_program, + ) + assert response.status_code == 201 + data = response.json() + assert data["points_per_euro"] == 1 + assert data["is_active"] is True + + def test_create_program_duplicate_fails( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Cannot create a second program for the same merchant.""" + response = client.post( + f"{BASE}/program", + json={"loyalty_type": "points"}, + headers=loyalty_merchant_headers, + ) + # Should fail - merchant already has a program + assert response.status_code in (400, 409, 422) + + +# ============================================================================ +# PATCH /program +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantUpdateProgram: + """Tests for PATCH /api/v1/merchants/loyalty/program.""" + + def test_update_program_success( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Update program fields.""" + response = client.patch( + f"{BASE}/program", + json={ + "points_per_euro": 20, + "card_name": "Updated Rewards", + }, + headers=loyalty_merchant_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["points_per_euro"] == 20 + assert data["card_name"] == "Updated Rewards" + + def test_update_program_partial( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Partial update only modifies specified fields.""" + response = client.patch( + f"{BASE}/program", + json={"card_name": "New Name Only"}, + headers=loyalty_merchant_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["card_name"] == "New Name Only" + # Other fields should be unchanged + assert data["points_per_euro"] == 10 + + def test_update_program_deactivate( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """Can deactivate program via update.""" + response = client.patch( + f"{BASE}/program", + json={"is_active": False}, + headers=loyalty_merchant_headers, + ) + assert response.status_code == 200 + assert response.json()["is_active"] is False + + def test_update_program_not_found( + self, client, loyalty_merchant_headers_no_program + ): + """Update returns 404 when no program exists.""" + response = client.patch( + f"{BASE}/program", + json={"card_name": "No Program"}, + headers=loyalty_merchant_headers_no_program, + ) + assert response.status_code == 404 + + +# ============================================================================ +# DELETE /program +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestMerchantDeleteProgram: + """Tests for DELETE /api/v1/merchants/loyalty/program.""" + + def test_delete_program_success( + self, client, loyalty_merchant_headers, loyalty_store_setup, db + ): + """Delete the merchant's loyalty program.""" + program_id = loyalty_store_setup["program"].id + + response = client.delete( + f"{BASE}/program", headers=loyalty_merchant_headers + ) + assert response.status_code == 204 + + # Verify program is deleted + from app.modules.loyalty.models import LoyaltyProgram + + deleted = db.get(LoyaltyProgram, program_id) + assert deleted is None + + def test_delete_program_not_found( + self, client, loyalty_merchant_headers_no_program + ): + """Delete returns 404 when no program exists.""" + response = client.delete( + f"{BASE}/program", headers=loyalty_merchant_headers_no_program + ) + assert response.status_code == 404 + + def test_delete_then_get_returns_404( + self, client, loyalty_merchant_headers, loyalty_store_setup + ): + """After deletion, GET returns 404.""" + client.delete(f"{BASE}/program", headers=loyalty_merchant_headers) + response = client.get( + f"{BASE}/program", headers=loyalty_merchant_headers + ) + assert response.status_code == 404 diff --git a/app/modules/loyalty/tests/integration/test_store_api.py b/app/modules/loyalty/tests/integration/test_store_api.py index 7c80d6d9..b529ea8b 100644 --- a/app/modules/loyalty/tests/integration/test_store_api.py +++ b/app/modules/loyalty/tests/integration/test_store_api.py @@ -2,11 +2,12 @@ """ Integration tests for store loyalty API endpoints. -Tests the endpoints fixed today: +Tests: - GET /cards/lookup (route ordering fix) - GET /cards/{card_id} (card detail) - GET /transactions (customer_name in response) - POST /points/earn (endpoint path rename) +- Store program CRUD endpoints removed (POST/PATCH /program) Authentication: Uses real JWT tokens via store login endpoint. """ @@ -213,3 +214,59 @@ class TestEarnPoints: ) # Should not be 404 (endpoint exists after rename) assert response.status_code != 404 + + +# ============================================================================ +# Store Program CRUD Removed +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.loyalty +class TestStoreProgramCrudRemoved: + """Verify POST/PATCH /program endpoints are removed from store API.""" + + def test_create_program_removed( + self, client, loyalty_store_headers + ): + """POST /program no longer exists on store API.""" + response = client.post( + f"{BASE}/program", + json={"loyalty_type": "points"}, + headers=loyalty_store_headers, + ) + assert response.status_code == 405 # Method Not Allowed + + def test_update_program_removed( + self, client, loyalty_store_headers + ): + """PATCH /program no longer exists on store API.""" + response = client.patch( + f"{BASE}/program", + json={"card_name": "Should Not Work"}, + headers=loyalty_store_headers, + ) + assert response.status_code == 405 # Method Not Allowed + + def test_get_program_still_works( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """GET /program still works (read-only).""" + response = client.get( + f"{BASE}/program", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == loyalty_store_setup["program"].id + + def test_stats_still_works( + self, client, loyalty_store_headers, loyalty_store_setup + ): + """GET /stats still works.""" + response = client.get( + f"{BASE}/stats", + headers=loyalty_store_headers, + ) + assert response.status_code == 200 diff --git a/app/modules/loyalty/tests/unit/test_program_service.py b/app/modules/loyalty/tests/unit/test_program_service.py index 92ff287f..6b26db34 100644 --- a/app/modules/loyalty/tests/unit/test_program_service.py +++ b/app/modules/loyalty/tests/unit/test_program_service.py @@ -1,8 +1,18 @@ """Unit tests for ProgramService.""" +import uuid + import pytest +from app.modules.loyalty.exceptions import ( + LoyaltyProgramAlreadyExistsException, + LoyaltyProgramNotFoundException, +) +from app.modules.loyalty.models import LoyaltyProgram +from app.modules.loyalty.models.loyalty_program import LoyaltyType +from app.modules.loyalty.schemas.program import ProgramCreate, ProgramUpdate from app.modules.loyalty.services.program_service import ProgramService +from app.modules.tenancy.models import Merchant, User @pytest.mark.unit @@ -16,3 +26,329 @@ class TestProgramService: def test_service_instantiation(self): """Service can be instantiated.""" assert self.service is not None + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def ps_merchant(db): + """Create a merchant for program service tests.""" + from middleware.auth import AuthManager + + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + + owner = User( + email=f"psowner_{uid}@test.com", + username=f"psowner_{uid}", + hashed_password=auth.hash_password("testpass123"), + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(owner) + db.commit() + db.refresh(owner) + + merchant = Merchant( + name=f"PS Test Merchant {uid}", + owner_user_id=owner.id, + contact_email=owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + return merchant + + +@pytest.fixture +def ps_program(db, ps_merchant): + """Create a program for program service tests.""" + program = LoyaltyProgram( + merchant_id=ps_merchant.id, + loyalty_type=LoyaltyType.POINTS.value, + points_per_euro=10, + welcome_bonus_points=50, + minimum_redemption_points=100, + minimum_purchase_cents=0, + cooldown_minutes=0, + max_daily_stamps=10, + require_staff_pin=False, + card_name="PS Test Rewards", + card_color="#4F46E5", + is_active=True, + points_rewards=[ + {"id": "reward_1", "name": "5 EUR off", "points_required": 100, "is_active": True}, + ], + ) + db.add(program) + db.commit() + db.refresh(program) + return program + + +# ============================================================================ +# Read Operations +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestGetProgram: + """Tests for get_program and get_program_by_merchant.""" + + def setup_method(self): + self.service = ProgramService() + + def test_get_program_by_id(self, db, ps_program): + """Get a program by its ID.""" + result = self.service.get_program(db, ps_program.id) + assert result is not None + assert result.id == ps_program.id + + def test_get_program_not_found(self, db): + """Returns None for non-existent program.""" + result = self.service.get_program(db, 999999) + assert result is None + + def test_get_program_by_merchant(self, db, ps_program, ps_merchant): + """Get a program by merchant ID.""" + result = self.service.get_program_by_merchant(db, ps_merchant.id) + assert result is not None + assert result.merchant_id == ps_merchant.id + + def test_get_program_by_merchant_not_found(self, db): + """Returns None when merchant has no program.""" + result = self.service.get_program_by_merchant(db, 999999) + assert result is None + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestRequireProgram: + """Tests for require_program and require_program_by_merchant.""" + + def setup_method(self): + self.service = ProgramService() + + def test_require_program_found(self, db, ps_program): + """Returns program when it exists.""" + result = self.service.require_program(db, ps_program.id) + assert result.id == ps_program.id + + def test_require_program_raises_not_found(self, db): + """Raises exception when program doesn't exist.""" + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.require_program(db, 999999) + + def test_require_program_by_merchant_found(self, db, ps_program, ps_merchant): + """Returns program for merchant.""" + result = self.service.require_program_by_merchant(db, ps_merchant.id) + assert result.merchant_id == ps_merchant.id + + def test_require_program_by_merchant_raises_not_found(self, db): + """Raises exception when merchant has no program.""" + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.require_program_by_merchant(db, 999999) + + +# ============================================================================ +# Create Operations +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestCreateProgram: + """Tests for create_program.""" + + def setup_method(self): + self.service = ProgramService() + + def test_create_program_points(self, db, ps_merchant): + """Create a points-based program.""" + data = ProgramCreate( + loyalty_type="points", + points_per_euro=5, + card_name="Unit Test Rewards", + card_color="#FF0000", + ) + program = self.service.create_program(db, ps_merchant.id, data) + + assert program.id is not None + assert program.merchant_id == ps_merchant.id + assert program.loyalty_type == "points" + assert program.points_per_euro == 5 + assert program.card_name == "Unit Test Rewards" + assert program.is_active is True + + def test_create_program_stamps(self, db, ps_merchant): + """Create a stamps-based program.""" + data = ProgramCreate( + loyalty_type="stamps", + stamps_target=8, + stamps_reward_description="Free coffee", + ) + program = self.service.create_program(db, ps_merchant.id, data) + + assert program.loyalty_type == "stamps" + assert program.stamps_target == 8 + assert program.stamps_reward_description == "Free coffee" + + def test_create_program_with_rewards(self, db, ps_merchant): + """Create program with configured rewards.""" + from app.modules.loyalty.schemas.program import PointsRewardConfig + + data = ProgramCreate( + loyalty_type="points", + points_per_euro=10, + points_rewards=[ + PointsRewardConfig( + id="r1", + name="5 EUR off", + points_required=100, + is_active=True, + ), + ], + ) + program = self.service.create_program(db, ps_merchant.id, data) + + assert len(program.points_rewards) == 1 + assert program.points_rewards[0]["name"] == "5 EUR off" + + def test_create_program_duplicate_raises(self, db, ps_program, ps_merchant): + """Cannot create two programs for the same merchant.""" + data = ProgramCreate(loyalty_type="points") + with pytest.raises(LoyaltyProgramAlreadyExistsException): + self.service.create_program(db, ps_merchant.id, data) + + def test_create_program_creates_merchant_settings(self, db, ps_merchant): + """Creating a program also creates merchant loyalty settings.""" + data = ProgramCreate(loyalty_type="points") + self.service.create_program(db, ps_merchant.id, data) + + settings = self.service.get_merchant_settings(db, ps_merchant.id) + assert settings is not None + + +# ============================================================================ +# Update Operations +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestUpdateProgram: + """Tests for update_program.""" + + def setup_method(self): + self.service = ProgramService() + + def test_update_program_fields(self, db, ps_program): + """Update specific fields.""" + data = ProgramUpdate(points_per_euro=20, card_name="Updated Name") + result = self.service.update_program(db, ps_program.id, data) + + assert result.points_per_euro == 20 + assert result.card_name == "Updated Name" + + def test_update_program_partial(self, db, ps_program): + """Partial update preserves unchanged fields.""" + original_points = ps_program.points_per_euro + data = ProgramUpdate(card_name="Only Name") + result = self.service.update_program(db, ps_program.id, data) + + assert result.card_name == "Only Name" + assert result.points_per_euro == original_points + + def test_update_nonexistent_raises(self, db): + """Updating non-existent program raises exception.""" + data = ProgramUpdate(card_name="Ghost") + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.update_program(db, 999999, data) + + +# ============================================================================ +# Activate / Deactivate +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestActivateDeactivate: + """Tests for activate_program and deactivate_program.""" + + def setup_method(self): + self.service = ProgramService() + + def test_deactivate_program(self, db, ps_program): + """Deactivate an active program.""" + assert ps_program.is_active is True + result = self.service.deactivate_program(db, ps_program.id) + assert result.is_active is False + + def test_activate_program(self, db, ps_program): + """Activate an inactive program.""" + ps_program.is_active = False + db.commit() + + result = self.service.activate_program(db, ps_program.id) + assert result.is_active is True + + def test_activate_nonexistent_raises(self, db): + """Activating non-existent program raises exception.""" + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.activate_program(db, 999999) + + def test_deactivate_nonexistent_raises(self, db): + """Deactivating non-existent program raises exception.""" + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.deactivate_program(db, 999999) + + +# ============================================================================ +# Delete Operations +# ============================================================================ + + +@pytest.mark.unit +@pytest.mark.loyalty +class TestDeleteProgram: + """Tests for delete_program.""" + + def setup_method(self): + self.service = ProgramService() + + def test_delete_program(self, db, ps_program): + """Delete a program removes it from DB.""" + program_id = ps_program.id + self.service.delete_program(db, program_id) + + result = self.service.get_program(db, program_id) + assert result is None + + def test_delete_program_also_deletes_settings(self, db, ps_merchant): + """Deleting a program also removes merchant settings.""" + data = ProgramCreate(loyalty_type="points") + program = self.service.create_program(db, ps_merchant.id, data) + + # Verify settings exist + settings = self.service.get_merchant_settings(db, ps_merchant.id) + assert settings is not None + + self.service.delete_program(db, program.id) + + # Settings should be gone too + settings = self.service.get_merchant_settings(db, ps_merchant.id) + assert settings is None + + def test_delete_nonexistent_raises(self, db): + """Deleting non-existent program raises exception.""" + with pytest.raises(LoyaltyProgramNotFoundException): + self.service.delete_program(db, 999999)