MukeshKapoor25 commited on
Commit
20ffa57
Β·
1 Parent(s): de24997

feat: Implement Gift Card Template API with CRUD operations

Browse files

- Added gift_card_router.py for handling API routes related to gift card templates.
- Created gift_card_schema.py to define Pydantic models for gift card templates, including validation rules.
- Developed gift_card_service.py to encapsulate business logic for creating, retrieving, updating, and deleting gift card templates.
- Introduced test_gift_card_implementation.py to validate schema functionality and ensure proper imports.
- Implemented error handling and logging throughout the service and router for better traceability.

GIFT_CARD_IMPLEMENTATION.md ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎯 Gift Card Template API Implementation
2
+
3
+ ## Overview
4
+ A complete end-to-end implementation of Gift Card Template management for the EMS (Entity Management System) microservice. This implementation provides full CRUD operations for gift card templates with comprehensive validation and business logic.
5
+
6
+ ## πŸ“ Files Created
7
+
8
+ ### 1. Models Layer
9
+ - **`app/models/gift_card_models.py`**
10
+ - `GiftCardTemplateModel` class with MongoDB operations
11
+ - Full CRUD operations (Create, Read, Update, Delete)
12
+ - Stock management for physical cards
13
+ - Automatic timestamp handling
14
+
15
+ ### 2. Schemas Layer
16
+ - **`app/schemas/gift_card_schema.py`**
17
+ - Pydantic models for request/response validation
18
+ - `GiftCardTemplateCreate` - Creation schema
19
+ - `GiftCardTemplateUpdate` - Update schema
20
+ - `GiftCardTemplateResponse` - Response schema
21
+ - `GiftCardTemplateFilter` - Filtering schema
22
+ - Comprehensive validation rules
23
+
24
+ ### 3. Repository Layer
25
+ - **`app/repositories/gift_card_repository.py`**
26
+ - `GiftCardRepository` class
27
+ - Clean interface between services and models
28
+ - Helper methods for common operations
29
+
30
+ ### 4. Service Layer
31
+ - **`app/services/gift_card_service.py`**
32
+ - `GiftCardService` class with business logic
33
+ - Validation of business rules
34
+ - Error handling and logging
35
+
36
+ ### 5. Router Layer
37
+ - **`app/routers/gift_card_router.py`**
38
+ - FastAPI router with 7 endpoints
39
+ - Comprehensive API documentation
40
+ - Permission-based access control
41
+
42
+ ### 6. App Registration
43
+ - **`app/app.py`** - Updated to include gift card router
44
+
45
+ ## πŸš€ API Endpoints
46
+
47
+ ### Core CRUD Operations
48
+ 1. **`POST /api/v1/giftcard/`** - Create gift card template
49
+ 2. **`GET /api/v1/giftcard/`** - List templates with filtering/pagination
50
+ 3. **`GET /api/v1/giftcard/{id}`** - Get specific template
51
+ 4. **`PUT /api/v1/giftcard/{id}`** - Update template
52
+ 5. **`DELETE /api/v1/giftcard/{id}`** - Delete template
53
+
54
+ ### Utility Endpoints
55
+ 6. **`GET /api/v1/giftcard/{id}/stock`** - Check stock availability
56
+ 7. **`GET /api/v1/giftcard/active/list`** - Get active templates
57
+
58
+ ## πŸ“Š Database Schema
59
+
60
+ ### Collection: `gift_card_templates`
61
+
62
+ | Field | Type | Required | Description |
63
+ |-------|------|----------|-------------|
64
+ | `_id` | string | βœ… | Unique template ID (tpl_xxxxxxxx) |
65
+ | `name` | string | βœ… | Template name |
66
+ | `description` | string | ❌ | Template description |
67
+ | `delivery_type` | enum | βœ… | `digital` or `physical` |
68
+ | `currency` | string | βœ… | ISO 4217 code (INR, USD, etc.) |
69
+ | `validity_days` | int/null | ❌ | Days valid from issuance |
70
+ | `allow_custom_amount` | boolean | βœ… | Allow custom amounts |
71
+ | `predefined_amounts` | number[] | ❌ | Fixed denominations |
72
+ | `reloadable` | boolean | βœ… | Can be topped up |
73
+ | `allow_partial_redemption` | boolean | βœ… | Multiple redemptions allowed |
74
+ | `requires_pin` | boolean | βœ… | PIN protection required |
75
+ | `requires_otp` | boolean | βœ… | OTP required for redemption |
76
+ | `status` | enum | βœ… | `active`, `inactive`, `archived` |
77
+ | `max_issues` | int/null | ❌ | Max cards (physical only) |
78
+ | `issued_count` | int | βœ… | Auto-incremented counter |
79
+ | `design_template` | object | ❌ | Design information |
80
+ | `branch_ids` | string[] | ❌ | Branch restrictions |
81
+ | `campaign_id` | string | ❌ | Campaign linkage |
82
+ | `metadata` | object | ❌ | Additional data |
83
+ | `merchant_id` | string | βœ… | Merchant identifier |
84
+ | `created_by` | string | βœ… | Creator user ID |
85
+ | `created_at` | datetime | βœ… | Creation timestamp |
86
+ | `updated_at` | datetime | ❌ | Update timestamp |
87
+
88
+ ## πŸ” Security Features
89
+
90
+ ### Authentication & Authorization
91
+ - JWT-based authentication via `get_current_user`
92
+ - Permission-based access control
93
+ - Merchant-level data isolation
94
+
95
+ ### Data Validation
96
+ - Comprehensive Pydantic validation
97
+ - Business rule enforcement
98
+ - Input sanitization
99
+
100
+ ## 🎯 Business Rules Implemented
101
+
102
+ ### Template Creation Rules
103
+ 1. **Physical cards** must have `max_issues` specified
104
+ 2. **Physical cards** cannot be `reloadable`
105
+ 3. Either `allow_custom_amount` or `predefined_amounts` required
106
+ 4. Template names must be non-empty
107
+ 5. Predefined amounts must be positive and unique
108
+
109
+ ### Template Update Rules
110
+ 1. Cannot change `delivery_type` of existing template
111
+ 2. Cannot make physical cards reloadable
112
+ 3. Cannot reduce `max_issues` below `issued_count`
113
+ 4. Physical cards must always have `max_issues`
114
+
115
+ ### Template Deletion Rules
116
+ 1. Cannot delete templates with `issued_count > 0`
117
+ 2. Recommend deactivation instead of deletion
118
+
119
+ ## πŸ“ Example Usage
120
+
121
+ ### Create Digital Template
122
+ ```json
123
+ POST /api/v1/giftcard/
124
+ {
125
+ "name": "Birthday Card",
126
+ "description": "Special card for birthdays",
127
+ "delivery_type": "digital",
128
+ "currency": "INR",
129
+ "validity_days": 365,
130
+ "allow_custom_amount": true,
131
+ "predefined_amounts": [500, 1000, 2000],
132
+ "reloadable": false,
133
+ "allow_partial_redemption": true,
134
+ "requires_pin": true,
135
+ "requires_otp": false,
136
+ "status": "active",
137
+ "design_template": {
138
+ "theme": "blue",
139
+ "image_url": "https://..."
140
+ }
141
+ }
142
+ ```
143
+
144
+ ### List Templates with Filtering
145
+ ```
146
+ GET /api/v1/giftcard/?status=active&delivery_type=digital&limit=50
147
+ ```
148
+
149
+ ### Update Template Status
150
+ ```json
151
+ PUT /api/v1/giftcard/tpl_12345678
152
+ {
153
+ "status": "inactive",
154
+ "predefined_amounts": [1000, 2000]
155
+ }
156
+ ```
157
+
158
+ ## πŸ§ͺ Testing
159
+
160
+ A comprehensive test script (`test_gift_card_implementation.py`) is provided that validates:
161
+ - Schema validation
162
+ - Business rule enforcement
163
+ - Module imports
164
+ - Error handling
165
+
166
+ ## πŸ“‹ Features Summary
167
+
168
+ βœ… **Complete CRUD Operations**
169
+ βœ… **MongoDB Integration**
170
+ βœ… **Pydantic Validation**
171
+ βœ… **Business Logic Layer**
172
+ βœ… **REST API Endpoints**
173
+ βœ… **Comprehensive Documentation**
174
+ βœ… **Error Handling**
175
+ βœ… **Permission System**
176
+ βœ… **Filtering & Pagination**
177
+ βœ… **Stock Management**
178
+ βœ… **Timestamp Tracking**
179
+
180
+ ## πŸš€ Next Steps
181
+
182
+ 1. **Database Setup** - Ensure MongoDB connection is configured
183
+ 2. **Dependencies** - Install required Python packages (FastAPI, Pydantic, Motor)
184
+ 3. **Testing** - Set up unit and integration tests
185
+ 4. **Deployment** - Deploy to staging/production environment
186
+ 5. **Integration** - Connect with gift card issuance system
187
+
188
+ ---
189
+
190
+ *Implementation completed according to the specified API endpoint specs and MongoDB schema requirements.*
app/app.py CHANGED
@@ -4,6 +4,7 @@ from app.routers.catalogue_router import router as catalogue_router
4
  from app.routers.supplier_route import router as supplier_router
5
  from app.routers.taxonomy_route import router as taxonomy_router
6
  from app.routers.promotion_router import router as promotion_router
 
7
 
8
  # Initialize FastAPI application
9
  app = FastAPI(
@@ -48,6 +49,12 @@ app.include_router(
48
  tags=["Taxonomy"]
49
  )
50
 
 
 
 
 
 
 
51
 
52
  # Optional root endpoint
53
  @app.get("/", tags=["Health"])
 
4
  from app.routers.supplier_route import router as supplier_router
5
  from app.routers.taxonomy_route import router as taxonomy_router
6
  from app.routers.promotion_router import router as promotion_router
7
+ from app.routers.gift_card_router import router as gift_card_router
8
 
9
  # Initialize FastAPI application
10
  app = FastAPI(
 
49
  tags=["Taxonomy"]
50
  )
51
 
52
+ app.include_router(
53
+ gift_card_router,
54
+ prefix="/api/v1/giftcard",
55
+ tags=["Gift Card"]
56
+ )
57
+
58
 
59
  # Optional root endpoint
60
  @app.get("/", tags=["Health"])
app/models/gift_card_models.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import uuid
3
+ from typing import Any, Dict, List, Optional
4
+ from datetime import datetime, timezone
5
+ from bson import ObjectId
6
+ from fastapi import HTTPException
7
+ from app.repositories.db import db
8
+
9
+ # Configure logging for this module
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Collection name
13
+ GIFT_CARD_TEMPLATES_COLLECTION = "gift_card_templates"
14
+
15
+ class GiftCardTemplateModel:
16
+ """
17
+ MongoDB model for Gift Card Templates.
18
+ Handles CRUD operations for gift card templates in MongoDB.
19
+ """
20
+
21
+ @staticmethod
22
+ async def create_template(data: Dict[str, Any]) -> str:
23
+ """
24
+ Create a new gift card template.
25
+
26
+ Args:
27
+ data (Dict[str, Any]): Template data to be inserted
28
+
29
+ Returns:
30
+ str: The inserted template ID
31
+
32
+ Raises:
33
+ RuntimeError: If the creation fails
34
+ """
35
+ try:
36
+ # Generate unique template ID
37
+ template_id = f"tpl_{str(uuid.uuid4())[:8]}"
38
+ data['_id'] = template_id
39
+ data['issued_count'] = 0 # Initialize issued count
40
+ data['created_at'] = datetime.now(timezone.utc)
41
+ data['updated_at'] = None
42
+
43
+ # Insert data into MongoDB collection
44
+ await db[GIFT_CARD_TEMPLATES_COLLECTION].insert_one(data)
45
+
46
+ # Log success for traceability
47
+ logger.info(f"Gift card template created successfully with ID: {template_id}")
48
+
49
+ return template_id
50
+ except Exception as e:
51
+ # Log the error with stack trace for debugging
52
+ logger.error(f"Error creating gift card template: {e}", exc_info=True)
53
+
54
+ # Raise a more specific error with a custom message
55
+ raise RuntimeError("Failed to create gift card template") from e
56
+
57
+ @staticmethod
58
+ async def get_templates(
59
+ filter_criteria: Dict[str, Any],
60
+ offset: int = 0,
61
+ limit: int = 100
62
+ ) -> Dict[str, Any]:
63
+ """
64
+ Get gift card templates with pagination and filtering.
65
+
66
+ Args:
67
+ filter_criteria (Dict[str, Any]): MongoDB filter criteria
68
+ offset (int): Number of documents to skip
69
+ limit (int): Maximum number of documents to return
70
+
71
+ Returns:
72
+ Dict[str, Any]: Dictionary containing templates and total count
73
+ """
74
+ try:
75
+ # Get total count
76
+ total = await db[GIFT_CARD_TEMPLATES_COLLECTION].count_documents(filter_criteria)
77
+
78
+ # Get paginated documents
79
+ cursor = db[GIFT_CARD_TEMPLATES_COLLECTION].find(filter_criteria)
80
+ cursor = cursor.skip(offset).limit(limit).sort("created_at", -1)
81
+
82
+ templates = await cursor.to_list(length=limit)
83
+
84
+ # Convert ObjectId to string for JSON serialization
85
+ for template in templates:
86
+ if isinstance(template.get('_id'), ObjectId):
87
+ template['_id'] = str(template['_id'])
88
+
89
+ logger.info(f"Retrieved {len(templates)} gift card templates")
90
+
91
+ return {
92
+ "templates": templates,
93
+ "total": total,
94
+ "offset": offset,
95
+ "limit": limit
96
+ }
97
+ except Exception as e:
98
+ logger.error(f"Error retrieving gift card templates: {e}", exc_info=True)
99
+ raise RuntimeError("Failed to retrieve gift card templates") from e
100
+
101
+ @staticmethod
102
+ async def get_template_by_id(template_id: str) -> Optional[Dict[str, Any]]:
103
+ """
104
+ Get a specific gift card template by ID.
105
+
106
+ Args:
107
+ template_id (str): The template ID to search for
108
+
109
+ Returns:
110
+ Optional[Dict[str, Any]]: Template data or None if not found
111
+ """
112
+ try:
113
+ template = await db[GIFT_CARD_TEMPLATES_COLLECTION].find_one({"_id": template_id})
114
+
115
+ if template:
116
+ # Convert ObjectId to string for JSON serialization
117
+ if isinstance(template.get('_id'), ObjectId):
118
+ template['_id'] = str(template['_id'])
119
+
120
+ logger.info(f"Gift card template {template_id} retrieved successfully")
121
+ else:
122
+ logger.info(f"Gift card template {template_id} not found")
123
+
124
+ return template
125
+ except Exception as e:
126
+ logger.error(f"Error retrieving gift card template {template_id}: {e}", exc_info=True)
127
+ raise RuntimeError("Failed to retrieve gift card template") from e
128
+
129
+ @staticmethod
130
+ async def update_template(template_id: str, update_data: Dict[str, Any]) -> bool:
131
+ """
132
+ Update a gift card template.
133
+
134
+ Args:
135
+ template_id (str): The template ID to update
136
+ update_data (Dict[str, Any]): Fields to update
137
+
138
+ Returns:
139
+ bool: True if the update was successful, False otherwise
140
+ """
141
+ try:
142
+ # Check if template exists
143
+ existing_template = await db[GIFT_CARD_TEMPLATES_COLLECTION].find_one({"_id": template_id})
144
+ if not existing_template:
145
+ raise HTTPException(status_code=404, detail="Gift card template not found")
146
+
147
+ # Add updated_at timestamp
148
+ update_data['updated_at'] = datetime.now(timezone.utc)
149
+
150
+ # Perform update
151
+ result = await db[GIFT_CARD_TEMPLATES_COLLECTION].update_one(
152
+ {"_id": template_id},
153
+ {"$set": update_data}
154
+ )
155
+
156
+ success = result.modified_count > 0
157
+
158
+ if success:
159
+ logger.info(f"Gift card template {template_id} updated successfully")
160
+ else:
161
+ logger.warning(f"No changes made to gift card template {template_id}")
162
+
163
+ return success
164
+ except HTTPException:
165
+ raise
166
+ except Exception as e:
167
+ logger.error(f"Error updating gift card template {template_id}: {e}", exc_info=True)
168
+ raise RuntimeError("Failed to update gift card template") from e
169
+
170
+ @staticmethod
171
+ async def delete_template(template_id: str) -> bool:
172
+ """
173
+ Delete a gift card template.
174
+
175
+ Args:
176
+ template_id (str): The template ID to delete
177
+
178
+ Returns:
179
+ bool: True if the deletion was successful, False otherwise
180
+ """
181
+ try:
182
+ result = await db[GIFT_CARD_TEMPLATES_COLLECTION].delete_one({"_id": template_id})
183
+
184
+ success = result.deleted_count > 0
185
+
186
+ if success:
187
+ logger.info(f"Gift card template {template_id} deleted successfully")
188
+ else:
189
+ logger.warning(f"Gift card template {template_id} not found for deletion")
190
+
191
+ return success
192
+ except Exception as e:
193
+ logger.error(f"Error deleting gift card template {template_id}: {e}", exc_info=True)
194
+ raise RuntimeError("Failed to delete gift card template") from e
195
+
196
+ @staticmethod
197
+ async def increment_issued_count(template_id: str) -> bool:
198
+ """
199
+ Increment the issued count for a template when a new gift card is issued.
200
+
201
+ Args:
202
+ template_id (str): The template ID to increment count for
203
+
204
+ Returns:
205
+ bool: True if the increment was successful, False otherwise
206
+ """
207
+ try:
208
+ result = await db[GIFT_CARD_TEMPLATES_COLLECTION].update_one(
209
+ {"_id": template_id},
210
+ {
211
+ "$inc": {"issued_count": 1},
212
+ "$set": {"updated_at": datetime.now(timezone.utc)}
213
+ }
214
+ )
215
+
216
+ success = result.modified_count > 0
217
+
218
+ if success:
219
+ logger.info(f"Issued count incremented for template {template_id}")
220
+ else:
221
+ logger.warning(f"Failed to increment issued count for template {template_id}")
222
+
223
+ return success
224
+ except Exception as e:
225
+ logger.error(f"Error incrementing issued count for template {template_id}: {e}", exc_info=True)
226
+ raise RuntimeError("Failed to increment issued count") from e
227
+
228
+ @staticmethod
229
+ async def check_stock_availability(template_id: str) -> Dict[str, Any]:
230
+ """
231
+ Check if a template has available stock (for physical cards with max_issues).
232
+
233
+ Args:
234
+ template_id (str): The template ID to check
235
+
236
+ Returns:
237
+ Dict[str, Any]: Stock availability information
238
+ """
239
+ try:
240
+ template = await db[GIFT_CARD_TEMPLATES_COLLECTION].find_one({"_id": template_id})
241
+
242
+ if not template:
243
+ raise HTTPException(status_code=404, detail="Gift card template not found")
244
+
245
+ max_issues = template.get("max_issues")
246
+ issued_count = template.get("issued_count", 0)
247
+
248
+ # For digital cards or unlimited templates
249
+ if max_issues is None:
250
+ return {
251
+ "available": True,
252
+ "unlimited": True,
253
+ "remaining": None
254
+ }
255
+
256
+ # For physical cards with limited stock
257
+ remaining = max_issues - issued_count
258
+ available = remaining > 0
259
+
260
+ return {
261
+ "available": available,
262
+ "unlimited": False,
263
+ "remaining": remaining,
264
+ "max_issues": max_issues,
265
+ "issued_count": issued_count
266
+ }
267
+ except HTTPException:
268
+ raise
269
+ except Exception as e:
270
+ logger.error(f"Error checking stock availability for template {template_id}: {e}", exc_info=True)
271
+ raise RuntimeError("Failed to check stock availability") from e
app/repositories/gift_card_repository.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, List, Optional
3
+ from app.models.gift_card_models import GiftCardTemplateModel
4
+ from app.schemas.gift_card_schema import GiftCardTemplateFilter
5
+
6
+ # Configure logging for this module
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class GiftCardRepository:
10
+ """
11
+ Repository layer for Gift Card operations.
12
+ Provides a clean interface between services and the data model.
13
+ """
14
+
15
+ @staticmethod
16
+ async def create_template(template_data: Dict[str, Any]) -> str:
17
+ """
18
+ Create a new gift card template.
19
+
20
+ Args:
21
+ template_data (Dict[str, Any]): Template data to create
22
+
23
+ Returns:
24
+ str: Created template ID
25
+ """
26
+ try:
27
+ template_id = await GiftCardTemplateModel.create_template(template_data)
28
+ logger.info(f"Template created with ID: {template_id}")
29
+ return template_id
30
+ except Exception as e:
31
+ logger.error(f"Repository error creating template: {e}")
32
+ raise
33
+
34
+ @staticmethod
35
+ async def get_templates(
36
+ filter_criteria: Dict[str, Any],
37
+ offset: int = 0,
38
+ limit: int = 100
39
+ ) -> Dict[str, Any]:
40
+ """
41
+ Get gift card templates with pagination and filtering.
42
+
43
+ Args:
44
+ filter_criteria (Dict[str, Any]): MongoDB filter criteria
45
+ offset (int): Number of documents to skip
46
+ limit (int): Maximum number of documents to return
47
+
48
+ Returns:
49
+ Dict[str, Any]: Templates with pagination info
50
+ """
51
+ try:
52
+ result = await GiftCardTemplateModel.get_templates(filter_criteria, offset, limit)
53
+ logger.info(f"Retrieved {len(result['templates'])} templates")
54
+ return result
55
+ except Exception as e:
56
+ logger.error(f"Repository error getting templates: {e}")
57
+ raise
58
+
59
+ @staticmethod
60
+ async def get_template_by_id(template_id: str) -> Optional[Dict[str, Any]]:
61
+ """
62
+ Get a specific gift card template by ID.
63
+
64
+ Args:
65
+ template_id (str): Template ID to retrieve
66
+
67
+ Returns:
68
+ Optional[Dict[str, Any]]: Template data or None if not found
69
+ """
70
+ try:
71
+ template = await GiftCardTemplateModel.get_template_by_id(template_id)
72
+ if template:
73
+ logger.info(f"Retrieved template: {template_id}")
74
+ else:
75
+ logger.info(f"Template not found: {template_id}")
76
+ return template
77
+ except Exception as e:
78
+ logger.error(f"Repository error getting template {template_id}: {e}")
79
+ raise
80
+
81
+ @staticmethod
82
+ async def update_template(template_id: str, update_data: Dict[str, Any]) -> bool:
83
+ """
84
+ Update a gift card template.
85
+
86
+ Args:
87
+ template_id (str): Template ID to update
88
+ update_data (Dict[str, Any]): Fields to update
89
+
90
+ Returns:
91
+ bool: True if update was successful
92
+ """
93
+ try:
94
+ success = await GiftCardTemplateModel.update_template(template_id, update_data)
95
+ if success:
96
+ logger.info(f"Template updated: {template_id}")
97
+ else:
98
+ logger.warning(f"No changes made to template: {template_id}")
99
+ return success
100
+ except Exception as e:
101
+ logger.error(f"Repository error updating template {template_id}: {e}")
102
+ raise
103
+
104
+ @staticmethod
105
+ async def delete_template(template_id: str) -> bool:
106
+ """
107
+ Delete a gift card template.
108
+
109
+ Args:
110
+ template_id (str): Template ID to delete
111
+
112
+ Returns:
113
+ bool: True if deletion was successful
114
+ """
115
+ try:
116
+ success = await GiftCardTemplateModel.delete_template(template_id)
117
+ if success:
118
+ logger.info(f"Template deleted: {template_id}")
119
+ else:
120
+ logger.warning(f"Template not found for deletion: {template_id}")
121
+ return success
122
+ except Exception as e:
123
+ logger.error(f"Repository error deleting template {template_id}: {e}")
124
+ raise
125
+
126
+ @staticmethod
127
+ async def check_template_exists(template_id: str) -> bool:
128
+ """
129
+ Check if a template exists.
130
+
131
+ Args:
132
+ template_id (str): Template ID to check
133
+
134
+ Returns:
135
+ bool: True if template exists
136
+ """
137
+ try:
138
+ template = await GiftCardTemplateModel.get_template_by_id(template_id)
139
+ return template is not None
140
+ except Exception as e:
141
+ logger.error(f"Repository error checking template existence {template_id}: {e}")
142
+ raise
143
+
144
+ @staticmethod
145
+ async def increment_issued_count(template_id: str) -> bool:
146
+ """
147
+ Increment the issued count for a template.
148
+
149
+ Args:
150
+ template_id (str): Template ID to increment count for
151
+
152
+ Returns:
153
+ bool: True if increment was successful
154
+ """
155
+ try:
156
+ success = await GiftCardTemplateModel.increment_issued_count(template_id)
157
+ if success:
158
+ logger.info(f"Issued count incremented for template: {template_id}")
159
+ return success
160
+ except Exception as e:
161
+ logger.error(f"Repository error incrementing issued count {template_id}: {e}")
162
+ raise
163
+
164
+ @staticmethod
165
+ async def check_stock_availability(template_id: str) -> Dict[str, Any]:
166
+ """
167
+ Check stock availability for a template.
168
+
169
+ Args:
170
+ template_id (str): Template ID to check
171
+
172
+ Returns:
173
+ Dict[str, Any]: Stock availability information
174
+ """
175
+ try:
176
+ stock_info = await GiftCardTemplateModel.check_stock_availability(template_id)
177
+ logger.info(f"Stock check for template {template_id}: available={stock_info['available']}")
178
+ return stock_info
179
+ except Exception as e:
180
+ logger.error(f"Repository error checking stock for template {template_id}: {e}")
181
+ raise
182
+
183
+ @staticmethod
184
+ def build_filter_criteria(
185
+ merchant_id: str,
186
+ filters: Optional[GiftCardTemplateFilter] = None
187
+ ) -> Dict[str, Any]:
188
+ """
189
+ Build MongoDB filter criteria from filter parameters.
190
+
191
+ Args:
192
+ merchant_id (str): Merchant ID to filter by
193
+ filters (Optional[GiftCardTemplateFilter]): Filter parameters
194
+
195
+ Returns:
196
+ Dict[str, Any]: MongoDB filter criteria
197
+ """
198
+ if filters:
199
+ return filters.to_mongo_filter(merchant_id)
200
+ else:
201
+ return {"merchant_id": merchant_id}
202
+
203
+ @staticmethod
204
+ async def get_templates_by_status(
205
+ merchant_id: str,
206
+ status: str,
207
+ limit: int = 100
208
+ ) -> List[Dict[str, Any]]:
209
+ """
210
+ Get templates by status for a specific merchant.
211
+
212
+ Args:
213
+ merchant_id (str): Merchant ID
214
+ status (str): Template status
215
+ limit (int): Maximum number of templates to return
216
+
217
+ Returns:
218
+ List[Dict[str, Any]]: List of templates
219
+ """
220
+ try:
221
+ filter_criteria = {"merchant_id": merchant_id, "status": status}
222
+ result = await GiftCardTemplateModel.get_templates(filter_criteria, 0, limit)
223
+ logger.info(f"Retrieved {len(result['templates'])} templates with status {status}")
224
+ return result["templates"]
225
+ except Exception as e:
226
+ logger.error(f"Repository error getting templates by status {status}: {e}")
227
+ raise
228
+
229
+ @staticmethod
230
+ async def xxget_active_templates(merchant_id: str, limit: int = 100) -> List[Dict[str, Any]]:
231
+ """
232
+ Get active templates for a specific merchant.
233
+
234
+ Args:
235
+ merchant_id (str): Merchant ID
236
+ limit (int): Maximum number of templates to return
237
+
238
+ Returns:
239
+ List[Dict[str, Any]]: List of active templates
240
+ """
241
+ return await GiftCardRepository.get_templates_by_status(merchant_id, "active", limit)
242
+
243
+ @staticmethod
244
+ async def get_templates_by_delivery_type(
245
+ merchant_id: str,
246
+ delivery_type: str,
247
+ limit: int = 100
248
+ ) -> List[Dict[str, Any]]:
249
+ """
250
+ Get templates by delivery type for a specific merchant.
251
+
252
+ Args:
253
+ merchant_id (str): Merchant ID
254
+ delivery_type (str): Delivery type (digital/physical)
255
+ limit (int): Maximum number of templates to return
256
+
257
+ Returns:
258
+ List[Dict[str, Any]]: List of templates
259
+ """
260
+ try:
261
+ filter_criteria = {"merchant_id": merchant_id, "delivery_type": delivery_type}
262
+ result = await GiftCardTemplateModel.get_templates(filter_criteria, 0, limit)
263
+ logger.info(f"Retrieved {len(result['templates'])} {delivery_type} templates")
264
+ return result["templates"]
265
+ except Exception as e:
266
+ logger.error(f"Repository error getting templates by delivery type {delivery_type}: {e}")
267
+ raise
app/routers/gift_card_router.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Optional
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, Path
4
+
5
+ from app.services.gift_card_service import GiftCardService
6
+ from app.schemas.gift_card_schema import (
7
+ GiftCardTemplateCreate,
8
+ GiftCardTemplateUpdate,
9
+ GiftCardTemplateResponse,
10
+ GiftCardTemplateListResponse,
11
+ GiftCardTemplateCreateResponse,
12
+ GiftCardTemplateFilter,
13
+ GiftCardStockResponse,
14
+ DeliveryType,
15
+ TemplateStatus,
16
+ Currency
17
+ )
18
+ from app.dependencies.auth import get_current_user, require_permission, AccessID
19
+
20
+ # Router Initialization
21
+ router = APIRouter()
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Constants
25
+ INTERNAL_SERVER_ERROR = "Internal server error"
26
+ MERCHANT_ID_REQUIRED = "Merchant ID is required"
27
+ USER_ID_REQUIRED = "User ID is required"
28
+ TEMPLATE_ID_DESCRIPTION = "Gift card template ID"
29
+
30
+ # Permission Dependencies (assuming similar pattern to catalogue router)
31
+ async def require_create_giftcard_permission(current_user: dict = Depends(get_current_user)):
32
+ return await require_permission(AccessID.CREATE_CATALOGUE.value, current_user) # Using catalogue permission for now
33
+
34
+ async def require_update_giftcard_permission(current_user: dict = Depends(get_current_user)):
35
+ return await require_permission(AccessID.UPDATE_CATALOGUE.value, current_user)
36
+
37
+ async def require_view_giftcard_permission(current_user: dict = Depends(get_current_user)):
38
+ return await require_permission(AccessID.VIEW_CATALOGUE.value, current_user)
39
+
40
+ async def require_delete_giftcard_permission(current_user: dict = Depends(get_current_user)):
41
+ return await require_permission(AccessID.DELETE_CATALOGUE.value, current_user)
42
+
43
+
44
+ # Route Handlers
45
+
46
+ @router.post("/", status_code=201, response_model=GiftCardTemplateCreateResponse)
47
+ async def create_gift_card_template(
48
+ template_data: GiftCardTemplateCreate,
49
+ current_user: dict = Depends(require_create_giftcard_permission)
50
+ ) -> GiftCardTemplateCreateResponse:
51
+ """
52
+ Create a new gift card template (blueprint).
53
+
54
+ This endpoint allows merchants to create gift card templates that define
55
+ the characteristics and rules for gift cards that can be issued later.
56
+
57
+ **Business Rules:**
58
+ - Physical cards must have max_issues specified
59
+ - Physical cards cannot be reloadable
60
+ - Either allow_custom_amount must be True or predefined_amounts must be provided
61
+ - Template names must be unique within a merchant
62
+
63
+ **Example Request:**
64
+ ```json
65
+ {
66
+ "name": "Birthday Card",
67
+ "description": "Special card for birthdays",
68
+ "delivery_type": "digital",
69
+ "currency": "INR",
70
+ "validity_days": 365,
71
+ "allow_custom_amount": true,
72
+ "predefined_amounts": [500, 1000, 2000],
73
+ "reloadable": false,
74
+ "allow_partial_redemption": true,
75
+ "requires_pin": true,
76
+ "requires_otp": false,
77
+ "status": "active",
78
+ "design_template": {
79
+ "theme": "blue",
80
+ "image_url": "https://..."
81
+ }
82
+ }
83
+ ```
84
+ """
85
+ try:
86
+ merchant_id = current_user.get("merchant_id")
87
+ user_id = current_user.get("user_id")
88
+
89
+ if not merchant_id:
90
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
91
+ if not user_id:
92
+ raise HTTPException(status_code=400, detail=USER_ID_REQUIRED)
93
+
94
+ logger.info(f"Creating gift card template for merchant {merchant_id} by user {user_id}")
95
+
96
+ result = await GiftCardService.create_template(template_data, merchant_id, user_id)
97
+
98
+ logger.info(f"Gift card template created successfully: {result.id}")
99
+ return result
100
+
101
+ except HTTPException:
102
+ raise
103
+ except Exception as e:
104
+ logger.error(f"Unexpected error creating gift card template: {e}", exc_info=True)
105
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
106
+
107
+
108
+ @router.get("/", response_model=GiftCardTemplateListResponse)
109
+ async def get_gift_card_templates(
110
+ status: Optional[TemplateStatus] = Query(None, description="Filter by template status"),
111
+ delivery_type: Optional[DeliveryType] = Query(None, description="Filter by delivery type"),
112
+ currency: Optional[Currency] = Query(None, description="Filter by currency"),
113
+ branch_id: Optional[str] = Query(None, description="Filter by branch ID"),
114
+ campaign_id: Optional[str] = Query(None, description="Filter by campaign ID"),
115
+ created_by: Optional[str] = Query(None, description="Filter by creator user ID"),
116
+ offset: int = Query(0, ge=0, description="Number of items to skip for pagination"),
117
+ limit: int = Query(100, ge=1, le=1000, description="Maximum number of items to return"),
118
+ current_user: dict = Depends(require_view_giftcard_permission)
119
+ ) -> GiftCardTemplateListResponse:
120
+ """
121
+ List all gift card templates with optional filtering.
122
+
123
+ This endpoint returns a paginated list of gift card templates for the merchant
124
+ with optional filtering by various criteria.
125
+
126
+ **Query Parameters:**
127
+ - **status**: Filter by template status (active, inactive, archived)
128
+ - **delivery_type**: Filter by delivery type (digital, physical)
129
+ - **currency**: Filter by currency (INR, USD, EUR, GBP)
130
+ - **branch_id**: Filter templates available for specific branch
131
+ - **campaign_id**: Filter templates linked to specific campaign
132
+ - **created_by**: Filter by creator user ID
133
+ - **offset**: Number of items to skip (for pagination)
134
+ - **limit**: Maximum items to return (1-1000)
135
+
136
+ **Response includes:**
137
+ - List of templates matching the criteria
138
+ - Total count of matching templates
139
+ - Pagination information
140
+ """
141
+ try:
142
+ merchant_id = current_user.get("merchant_id")
143
+
144
+ if not merchant_id:
145
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
146
+
147
+ # Build filter object
148
+ filters = GiftCardTemplateFilter(
149
+ status=status,
150
+ delivery_type=delivery_type,
151
+ currency=currency,
152
+ branch_id=branch_id,
153
+ campaign_id=campaign_id,
154
+ created_by=created_by
155
+ )
156
+
157
+ logger.info(f"Fetching gift card templates for merchant {merchant_id}")
158
+
159
+ result = await GiftCardService.get_templates(merchant_id, filters, offset, limit)
160
+
161
+ logger.info(f"Retrieved {len(result.templates)} gift card templates")
162
+ return result
163
+
164
+ except HTTPException:
165
+ raise
166
+ except Exception as e:
167
+ logger.error(f"Unexpected error fetching gift card templates: {e}", exc_info=True)
168
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
169
+
170
+
171
+ @router.get("/{template_id}", response_model=GiftCardTemplateResponse)
172
+ async def get_gift_card_template(
173
+ template_id: str = Path(..., description=TEMPLATE_ID_DESCRIPTION),
174
+ current_user: dict = Depends(require_view_giftcard_permission)
175
+ ) -> GiftCardTemplateResponse:
176
+ """
177
+ Get a specific gift card template by ID.
178
+
179
+ This endpoint returns detailed information about a specific gift card template.
180
+
181
+ **Path Parameters:**
182
+ - **template_id**: The unique identifier of the gift card template
183
+
184
+ **Security:**
185
+ - Only returns templates that belong to the authenticated merchant
186
+ - Returns 404 if template doesn't exist or doesn't belong to merchant
187
+ """
188
+ try:
189
+ merchant_id = current_user.get("merchant_id")
190
+
191
+ if not merchant_id:
192
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
193
+
194
+ logger.info(f"Fetching gift card template {template_id} for merchant {merchant_id}")
195
+
196
+ result = await GiftCardService.get_template_by_id(template_id, merchant_id)
197
+
198
+ logger.info(f"Retrieved gift card template: {template_id}")
199
+ return result
200
+
201
+ except HTTPException:
202
+ raise
203
+ except Exception as e:
204
+ logger.error(f"Unexpected error fetching gift card template {template_id}: {e}", exc_info=True)
205
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
206
+
207
+
208
+ @router.put("/{template_id}", response_model=GiftCardTemplateResponse)
209
+ async def update_gift_card_template(
210
+ template_id: str = Path(..., description=TEMPLATE_ID_DESCRIPTION),
211
+ update_data: GiftCardTemplateUpdate = ...,
212
+ current_user: dict = Depends(require_update_giftcard_permission)
213
+ ) -> GiftCardTemplateResponse:
214
+ """
215
+ Update an existing gift card template.
216
+
217
+ This endpoint allows updating specific fields of a gift card template.
218
+ Some fields have restrictions based on business rules.
219
+
220
+ **Path Parameters:**
221
+ - **template_id**: The unique identifier of the gift card template
222
+
223
+ **Business Rules:**
224
+ - Cannot change delivery_type of existing template
225
+ - Physical cards cannot be made reloadable
226
+ - Cannot reduce max_issues below current issued_count
227
+ - Physical cards must always have max_issues specified
228
+
229
+ **Common Use Cases:**
230
+ - Deactivate template: `{"status": "inactive"}`
231
+ - Update predefined amounts: `{"predefined_amounts": [1000, 2000]}`
232
+ - Change design: `{"design_template": {"theme": "gold", "image_url": "..."}}`
233
+
234
+ **Example Request:**
235
+ ```json
236
+ {
237
+ "status": "inactive",
238
+ "predefined_amounts": [1000, 2000]
239
+ }
240
+ ```
241
+ """
242
+ try:
243
+ merchant_id = current_user.get("merchant_id")
244
+
245
+ if not merchant_id:
246
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
247
+
248
+ logger.info(f"Updating gift card template {template_id} for merchant {merchant_id}")
249
+
250
+ result = await GiftCardService.update_template(template_id, update_data, merchant_id)
251
+
252
+ logger.info(f"Gift card template updated successfully: {template_id}")
253
+ return result
254
+
255
+ except HTTPException:
256
+ raise
257
+ except Exception as e:
258
+ logger.error(f"Unexpected error updating gift card template {template_id}: {e}", exc_info=True)
259
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
260
+
261
+
262
+ @router.delete("/{template_id}")
263
+ async def delete_gift_card_template(
264
+ template_id: str = Path(..., description=TEMPLATE_ID_DESCRIPTION),
265
+ current_user: dict = Depends(require_delete_giftcard_permission)
266
+ ) -> dict:
267
+ """
268
+ Delete a gift card template.
269
+
270
+ This endpoint deletes a gift card template. Templates can only be deleted
271
+ if no gift cards have been issued from them.
272
+
273
+ **Path Parameters:**
274
+ - **template_id**: The unique identifier of the gift card template
275
+
276
+ **Business Rules:**
277
+ - Cannot delete templates that have issued_count > 0
278
+ - Consider deactivating instead of deleting for templates with issued cards
279
+
280
+ **Security:**
281
+ - Only allows deletion of templates that belong to the authenticated merchant
282
+ """
283
+ try:
284
+ merchant_id = current_user.get("merchant_id")
285
+
286
+ if not merchant_id:
287
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
288
+
289
+ logger.info(f"Deleting gift card template {template_id} for merchant {merchant_id}")
290
+
291
+ result = await GiftCardService.delete_template(template_id, merchant_id)
292
+
293
+ logger.info(f"Gift card template deleted successfully: {template_id}")
294
+ return result
295
+
296
+ except HTTPException:
297
+ raise
298
+ except Exception as e:
299
+ logger.error(f"Unexpected error deleting gift card template {template_id}: {e}", exc_info=True)
300
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
301
+
302
+
303
+ # Additional utility endpoints
304
+
305
+ @router.get("/{template_id}/stock", response_model=GiftCardStockResponse)
306
+ async def check_gift_card_template_stock(
307
+ template_id: str = Path(..., description=TEMPLATE_ID_DESCRIPTION),
308
+ current_user: dict = Depends(require_view_giftcard_permission)
309
+ ) -> GiftCardStockResponse:
310
+ """
311
+ Check stock availability for a gift card template.
312
+
313
+ This endpoint returns information about whether a template has available
314
+ stock for issuing new gift cards.
315
+
316
+ **Path Parameters:**
317
+ - **template_id**: The unique identifier of the gift card template
318
+
319
+ **Response:**
320
+ - **available**: Whether cards can be issued from this template
321
+ - **unlimited**: Whether the template has unlimited stock (digital cards)
322
+ - **remaining**: Number of cards remaining (null for unlimited)
323
+ - **max_issues**: Maximum number of cards that can be issued
324
+ - **issued_count**: Number of cards already issued
325
+ """
326
+ try:
327
+ merchant_id = current_user.get("merchant_id")
328
+
329
+ if not merchant_id:
330
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
331
+
332
+ logger.info(f"Checking stock for gift card template {template_id}")
333
+
334
+ result = await GiftCardService.check_stock_availability(template_id, merchant_id)
335
+
336
+ return result
337
+
338
+ except HTTPException:
339
+ raise
340
+ except Exception as e:
341
+ logger.error(f"Unexpected error checking stock for template {template_id}: {e}", exc_info=True)
342
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
343
+
344
+
345
+ @router.get("/active/list", response_model=list[GiftCardTemplateResponse])
346
+ async def get_active_gift_card_templates(
347
+ current_user: dict = Depends(require_view_giftcard_permission)
348
+ ) -> list[GiftCardTemplateResponse]:
349
+ """
350
+ Get all active gift card templates for the merchant.
351
+
352
+ This is a convenience endpoint that returns only active templates,
353
+ commonly used for displaying available templates to customers.
354
+
355
+ **Returns:**
356
+ - List of all active gift card templates for the merchant
357
+ - Templates are sorted by creation date (newest first)
358
+ """
359
+ try:
360
+ merchant_id = current_user.get("merchant_id")
361
+
362
+ if not merchant_id:
363
+ raise HTTPException(status_code=400, detail=MERCHANT_ID_REQUIRED)
364
+
365
+ logger.info(f"Fetching active gift card templates for merchant {merchant_id}")
366
+
367
+ result = await GiftCardService.get_active_templates(merchant_id)
368
+
369
+ logger.info(f"Retrieved {len(result)} active gift card templates")
370
+ return result
371
+
372
+ except HTTPException:
373
+ raise
374
+ except Exception as e:
375
+ logger.error(f"Unexpected error fetching active gift card templates: {e}", exc_info=True)
376
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
app/schemas/gift_card_schema.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Any, Dict, List, Literal, Optional, Union
3
+ from pydantic import BaseModel, Field, model_validator, field_validator
4
+
5
+
6
+ # Enums for gift card template fields
7
+ DeliveryType = Literal["digital", "physical"]
8
+ TemplateStatus = Literal["active", "inactive", "archived"]
9
+ Currency = Literal["INR", "USD", "EUR", "GBP"] # Add more currencies as needed
10
+
11
+
12
+ # Design template schema
13
+ class DesignTemplate(BaseModel):
14
+ """Design template configuration for gift cards."""
15
+ theme: str = Field(..., description="Theme name (e.g., 'blue', 'gold')")
16
+ image_url: Optional[str] = Field(None, description="URL to the template image")
17
+
18
+ @field_validator('theme')
19
+ @classmethod
20
+ def validate_theme(cls, v):
21
+ if not v.strip():
22
+ raise ValueError("Theme cannot be empty")
23
+ return v.strip()
24
+
25
+
26
+ # Base gift card template schema
27
+ class GiftCardTemplateBase(BaseModel):
28
+ """Base schema for gift card templates."""
29
+ name: str = Field(..., description="Template name (e.g., 'Birthday Gift Card')", min_length=1, max_length=100)
30
+ description: Optional[str] = Field(None, description="Longer description for admins/marketing", max_length=500)
31
+ delivery_type: DeliveryType = Field(..., description="Delivery type: digital or physical")
32
+ currency: Currency = Field(..., description="ISO 4217 currency code")
33
+ validity_days: Optional[int] = Field(None, description="Days card is valid from issuance; null = no expiry", ge=1)
34
+ allow_custom_amount: bool = Field(..., description="Allow purchaser to enter custom value")
35
+ predefined_amounts: Optional[List[float]] = Field(None, description="Array of fixed denominations")
36
+ reloadable: bool = Field(..., description="Can the card be topped up after issue?")
37
+ allow_partial_redemption: bool = Field(..., description="Can recipient use the card multiple times until balance is zero?")
38
+ requires_pin: bool = Field(..., description="If true, issued cards must have PIN protection")
39
+ requires_otp: bool = Field(..., description="If true, OTP required at redemption")
40
+ status: TemplateStatus = Field(..., description="Template status")
41
+ max_issues: Optional[int] = Field(None, description="For physical cards: max number available; For digital: null = unlimited", ge=1)
42
+ design_template: Optional[DesignTemplate] = Field(None, description="Branding information")
43
+ branch_ids: Optional[List[str]] = Field(None, description="Restrict usage to specific branches (empty = all)")
44
+ campaign_id: Optional[str] = Field(None, description="Link to campaign (if promotional)")
45
+ metadata: Optional[Dict[str, Any]] = Field(None, description="Freeform extra data")
46
+ created_by: str = Field(..., description="User ID who created template")
47
+
48
+ @field_validator('name')
49
+ @classmethod
50
+ def validate_name(cls, v):
51
+ if not v.strip():
52
+ raise ValueError("Name cannot be empty")
53
+ return v.strip()
54
+
55
+ @field_validator('predefined_amounts')
56
+ @classmethod
57
+ def validate_predefined_amounts(cls, v):
58
+ if v is not None:
59
+ if not v: # Empty list
60
+ raise ValueError("Predefined amounts cannot be an empty list; use null instead")
61
+ if any(amount <= 0 for amount in v):
62
+ raise ValueError("All predefined amounts must be positive")
63
+ if len(set(v)) != len(v):
64
+ raise ValueError("Predefined amounts must be unique")
65
+ return v
66
+
67
+ @field_validator('branch_ids')
68
+ @classmethod
69
+ def validate_branch_ids(cls, v):
70
+ if v is not None and not v: # Empty list
71
+ return None # Convert empty list to None
72
+ return v
73
+
74
+ @model_validator(mode='after')
75
+ def validate_amounts_configuration(self):
76
+ """Validate that either custom amounts or predefined amounts are allowed."""
77
+ if not self.allow_custom_amount and not self.predefined_amounts:
78
+ raise ValueError("Either allow_custom_amount must be True or predefined_amounts must be provided")
79
+ return self
80
+
81
+ @model_validator(mode='after')
82
+ def validate_physical_card_constraints(self):
83
+ """Validate constraints specific to physical cards."""
84
+ if self.delivery_type == "physical":
85
+ if self.max_issues is None:
86
+ raise ValueError("Physical cards must have max_issues specified")
87
+ if self.reloadable:
88
+ raise ValueError("Physical cards cannot be reloadable")
89
+ return self
90
+
91
+
92
+ # Create gift card template request schema
93
+ class GiftCardTemplateCreate(GiftCardTemplateBase):
94
+ """Schema for creating a new gift card template."""
95
+ pass
96
+
97
+
98
+ # Update gift card template request schema
99
+ class GiftCardTemplateUpdate(BaseModel):
100
+ """Schema for updating an existing gift card template."""
101
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
102
+ description: Optional[str] = Field(None, max_length=500)
103
+ validity_days: Optional[int] = Field(None, ge=1)
104
+ allow_custom_amount: Optional[bool] = None
105
+ predefined_amounts: Optional[List[float]] = None
106
+ reloadable: Optional[bool] = None
107
+ allow_partial_redemption: Optional[bool] = None
108
+ requires_pin: Optional[bool] = None
109
+ requires_otp: Optional[bool] = None
110
+ status: Optional[TemplateStatus] = None
111
+ max_issues: Optional[int] = Field(None, ge=1)
112
+ design_template: Optional[DesignTemplate] = None
113
+ branch_ids: Optional[List[str]] = None
114
+ campaign_id: Optional[str] = None
115
+ metadata: Optional[Dict[str, Any]] = None
116
+
117
+ @field_validator('name')
118
+ @classmethod
119
+ def validate_name(cls, v):
120
+ if v is not None and not v.strip():
121
+ raise ValueError("Name cannot be empty")
122
+ return v.strip() if v else v
123
+
124
+ @field_validator('predefined_amounts')
125
+ @classmethod
126
+ def validate_predefined_amounts(cls, v):
127
+ if v is not None:
128
+ if not v: # Empty list
129
+ raise ValueError("Predefined amounts cannot be an empty list; use null instead")
130
+ if any(amount <= 0 for amount in v):
131
+ raise ValueError("All predefined amounts must be positive")
132
+ if len(set(v)) != len(v):
133
+ raise ValueError("Predefined amounts must be unique")
134
+ return v
135
+
136
+ @field_validator('branch_ids')
137
+ @classmethod
138
+ def validate_branch_ids(cls, v):
139
+ if v is not None and not v: # Empty list
140
+ return None # Convert empty list to None
141
+ return v
142
+
143
+
144
+ # Gift card template response schema
145
+ class GiftCardTemplateResponse(GiftCardTemplateBase):
146
+ """Schema for gift card template responses."""
147
+ id: str = Field(..., description="Unique template ID", alias="_id")
148
+ issued_count: int = Field(..., description="Number of cards issued from this template")
149
+ created_at: datetime = Field(..., description="Creation timestamp")
150
+ updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
151
+
152
+ class Config:
153
+ populate_by_name = True
154
+ json_encoders = {
155
+ datetime: lambda v: v.isoformat() if v else None
156
+ }
157
+
158
+
159
+ # Gift card template list response schema
160
+ class GiftCardTemplateListResponse(BaseModel):
161
+ """Schema for paginated gift card template list responses."""
162
+ templates: List[GiftCardTemplateResponse] = Field(..., description="List of gift card templates")
163
+ total: int = Field(..., description="Total number of templates matching the filter")
164
+ offset: int = Field(..., description="Number of items skipped")
165
+ limit: int = Field(..., description="Maximum number of items returned")
166
+
167
+
168
+ # Simple create response schema
169
+ class GiftCardTemplateCreateResponse(BaseModel):
170
+ """Schema for gift card template creation response."""
171
+ id: str = Field(..., description="Unique template ID")
172
+ name: str = Field(..., description="Template name")
173
+ status: TemplateStatus = Field(..., description="Template status")
174
+ created_at: datetime = Field(..., description="Creation timestamp")
175
+
176
+ class Config:
177
+ json_encoders = {
178
+ datetime: lambda v: v.isoformat() if v else None
179
+ }
180
+
181
+
182
+ # Stock availability response schema
183
+ class GiftCardStockResponse(BaseModel):
184
+ """Schema for gift card stock availability response."""
185
+ available: bool = Field(..., description="Whether the template has available stock")
186
+ unlimited: bool = Field(..., description="Whether the template has unlimited stock")
187
+ remaining: Optional[int] = Field(None, description="Number of cards remaining (null for unlimited)")
188
+ max_issues: Optional[int] = Field(None, description="Maximum number of cards that can be issued")
189
+ issued_count: Optional[int] = Field(None, description="Number of cards already issued")
190
+
191
+
192
+ # Filter schema for listing templates
193
+ class GiftCardTemplateFilter(BaseModel):
194
+ """Schema for filtering gift card templates."""
195
+ status: Optional[TemplateStatus] = Field(None, description="Filter by template status")
196
+ delivery_type: Optional[DeliveryType] = Field(None, description="Filter by delivery type")
197
+ currency: Optional[Currency] = Field(None, description="Filter by currency")
198
+ branch_id: Optional[str] = Field(None, description="Filter by branch ID")
199
+ campaign_id: Optional[str] = Field(None, description="Filter by campaign ID")
200
+ created_by: Optional[str] = Field(None, description="Filter by creator user ID")
201
+
202
+ def to_mongo_filter(self, merchant_id: str) -> Dict[str, Any]:
203
+ """Convert filter to MongoDB query format."""
204
+ mongo_filter = {"merchant_id": merchant_id} # Always filter by merchant
205
+
206
+ if self.status:
207
+ mongo_filter["status"] = self.status
208
+ if self.delivery_type:
209
+ mongo_filter["delivery_type"] = self.delivery_type
210
+ if self.currency:
211
+ mongo_filter["currency"] = self.currency
212
+ if self.branch_id:
213
+ mongo_filter["$or"] = [
214
+ {"branch_ids": {"$in": [self.branch_id]}},
215
+ {"branch_ids": {"$exists": False}},
216
+ {"branch_ids": None},
217
+ {"branch_ids": []}
218
+ ]
219
+ if self.campaign_id:
220
+ mongo_filter["campaign_id"] = self.campaign_id
221
+ if self.created_by:
222
+ mongo_filter["created_by"] = self.created_by
223
+
224
+ return mongo_filter
app/services/gift_card_service.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, List, Optional
3
+ from fastapi import HTTPException
4
+ from pydantic import ValidationError
5
+
6
+ from app.repositories.gift_card_repository import GiftCardRepository
7
+ from app.schemas.gift_card_schema import (
8
+ GiftCardTemplateCreate,
9
+ GiftCardTemplateUpdate,
10
+ GiftCardTemplateResponse,
11
+ GiftCardTemplateListResponse,
12
+ GiftCardTemplateCreateResponse,
13
+ GiftCardTemplateFilter,
14
+ GiftCardStockResponse
15
+ )
16
+
17
+ # Configure logging for this module
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Constants for error messages
21
+ INTERNAL_SERVER_ERROR = "Internal server error"
22
+ TEMPLATE_NOT_FOUND = "Gift card template not found"
23
+ TEMPLATE_CREATION_FAILED = "Failed to create gift card template"
24
+ TEMPLATE_UPDATE_FAILED = "Failed to update gift card template"
25
+ TEMPLATE_DELETE_FAILED = "Failed to delete gift card template"
26
+
27
+ class GiftCardService:
28
+ """
29
+ Service layer for Gift Card operations.
30
+ Contains business logic and validation for gift card templates.
31
+ """
32
+
33
+ @staticmethod
34
+ async def create_template(
35
+ template_data: GiftCardTemplateCreate,
36
+ merchant_id: str,
37
+ created_by: str
38
+ ) -> GiftCardTemplateCreateResponse:
39
+ """
40
+ Create a new gift card template.
41
+
42
+ Args:
43
+ template_data (GiftCardTemplateCreate): Template data to create
44
+ merchant_id (str): Merchant ID
45
+ created_by (str): User ID who is creating the template
46
+
47
+ Returns:
48
+ GiftCardTemplateCreateResponse: Created template info
49
+
50
+ Raises:
51
+ HTTPException: If creation fails
52
+ """
53
+ try:
54
+ # Convert Pydantic model to dict and add merchant info
55
+ data = template_data.model_dump(by_alias=True, exclude_none=True)
56
+ data['merchant_id'] = merchant_id
57
+ data['created_by'] = created_by
58
+
59
+ logger.info(f"Creating gift card template for merchant {merchant_id}")
60
+
61
+ # Create template via repository
62
+ template_id = await GiftCardRepository.create_template(data)
63
+
64
+ # Get the created template to return full info
65
+ created_template = await GiftCardRepository.get_template_by_id(template_id)
66
+ if not created_template:
67
+ raise HTTPException(status_code=500, detail="Template created but could not be retrieved")
68
+
69
+ return GiftCardTemplateCreateResponse(
70
+ id=created_template['_id'],
71
+ name=created_template['name'],
72
+ status=created_template['status'],
73
+ created_at=created_template['created_at']
74
+ )
75
+
76
+ except ValidationError as ve:
77
+ logger.error(f"Validation error creating template: {ve}")
78
+ raise HTTPException(status_code=400, detail=f"Validation error: {ve}")
79
+ except HTTPException:
80
+ raise
81
+ except Exception as e:
82
+ logger.error(f"Service error creating template: {e}", exc_info=True)
83
+ raise HTTPException(status_code=500, detail=TEMPLATE_CREATION_FAILED)
84
+
85
+ @staticmethod
86
+ async def get_templates(
87
+ merchant_id: str,
88
+ filters: Optional[GiftCardTemplateFilter] = None,
89
+ offset: int = 0,
90
+ limit: int = 100
91
+ ) -> GiftCardTemplateListResponse:
92
+ """
93
+ Get gift card templates with pagination and filtering.
94
+
95
+ Args:
96
+ merchant_id (str): Merchant ID
97
+ filters (Optional[GiftCardTemplateFilter]): Filter criteria
98
+ offset (int): Number of items to skip
99
+ limit (int): Maximum number of items to return
100
+
101
+ Returns:
102
+ GiftCardTemplateListResponse: Paginated list of templates
103
+
104
+ Raises:
105
+ HTTPException: If retrieval fails
106
+ """
107
+ try:
108
+ # Build filter criteria
109
+ filter_criteria = GiftCardRepository.build_filter_criteria(merchant_id, filters)
110
+
111
+ logger.info(f"Fetching templates for merchant {merchant_id} with filters: {filter_criteria}")
112
+
113
+ # Get templates from repository
114
+ result = await GiftCardRepository.get_templates(filter_criteria, offset, limit)
115
+
116
+ # Convert to response models
117
+ templates = []
118
+ for template_data in result['templates']:
119
+ try:
120
+ templates.append(GiftCardTemplateResponse(**template_data))
121
+ except ValidationError as ve:
122
+ logger.warning(f"Failed to parse template: {template_data}, error: {ve}")
123
+ continue
124
+
125
+ return GiftCardTemplateListResponse(
126
+ templates=templates,
127
+ total=result['total'],
128
+ offset=offset,
129
+ limit=limit
130
+ )
131
+
132
+ except HTTPException:
133
+ raise
134
+ except Exception as e:
135
+ logger.error(f"Service error getting templates: {e}", exc_info=True)
136
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
137
+
138
+ @staticmethod
139
+ async def get_template_by_id(
140
+ template_id: str,
141
+ merchant_id: str
142
+ ) -> GiftCardTemplateResponse:
143
+ """
144
+ Get a specific gift card template by ID.
145
+
146
+ Args:
147
+ template_id (str): Template ID to retrieve
148
+ merchant_id (str): Merchant ID for security
149
+
150
+ Returns:
151
+ GiftCardTemplateResponse: Template data
152
+
153
+ Raises:
154
+ HTTPException: If template not found or access denied
155
+ """
156
+ try:
157
+ logger.info(f"Fetching template {template_id} for merchant {merchant_id}")
158
+
159
+ template = await GiftCardRepository.get_template_by_id(template_id)
160
+
161
+ if not template:
162
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
163
+
164
+ # Check if template belongs to the merchant
165
+ if template.get('merchant_id') != merchant_id:
166
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
167
+
168
+ return GiftCardTemplateResponse(**template)
169
+
170
+ except HTTPException:
171
+ raise
172
+ except ValidationError as ve:
173
+ logger.error(f"Validation error parsing template {template_id}: {ve}")
174
+ raise HTTPException(status_code=500, detail="Template data validation failed")
175
+ except Exception as e:
176
+ logger.error(f"Service error getting template {template_id}: {e}", exc_info=True)
177
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
178
+
179
+ @staticmethod
180
+ async def update_template(
181
+ template_id: str,
182
+ update_data: GiftCardTemplateUpdate,
183
+ merchant_id: str
184
+ ) -> GiftCardTemplateResponse:
185
+ """
186
+ Update a gift card template.
187
+
188
+ Args:
189
+ template_id (str): Template ID to update
190
+ update_data (GiftCardTemplateUpdate): Update data
191
+ merchant_id (str): Merchant ID for security
192
+
193
+ Returns:
194
+ GiftCardTemplateResponse: Updated template data
195
+
196
+ Raises:
197
+ HTTPException: If update fails or template not found
198
+ """
199
+ try:
200
+ logger.info(f"Updating template {template_id} for merchant {merchant_id}")
201
+
202
+ # First verify the template exists and belongs to the merchant
203
+ existing_template = await GiftCardRepository.get_template_by_id(template_id)
204
+ if not existing_template:
205
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
206
+
207
+ if existing_template.get('merchant_id') != merchant_id:
208
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
209
+
210
+ # Convert update data to dict, excluding None values
211
+ update_dict = update_data.model_dump(by_alias=True, exclude_none=True)
212
+
213
+ if not update_dict:
214
+ raise HTTPException(status_code=400, detail="No update data provided")
215
+
216
+ # Validate business rules for updates
217
+ await GiftCardService._validate_template_update(existing_template, update_dict)
218
+
219
+ # Perform update
220
+ success = await GiftCardRepository.update_template(template_id, update_dict)
221
+
222
+ if not success:
223
+ raise HTTPException(status_code=500, detail=TEMPLATE_UPDATE_FAILED)
224
+
225
+ # Return updated template
226
+ updated_template = await GiftCardRepository.get_template_by_id(template_id)
227
+ return GiftCardTemplateResponse(**updated_template)
228
+
229
+ except HTTPException:
230
+ raise
231
+ except ValidationError as ve:
232
+ logger.error(f"Validation error updating template {template_id}: {ve}")
233
+ raise HTTPException(status_code=400, detail=f"Validation error: {ve}")
234
+ except Exception as e:
235
+ logger.error(f"Service error updating template {template_id}: {e}", exc_info=True)
236
+ raise HTTPException(status_code=500, detail=TEMPLATE_UPDATE_FAILED)
237
+
238
+ @staticmethod
239
+ async def delete_template(
240
+ template_id: str,
241
+ merchant_id: str
242
+ ) -> Dict[str, str]:
243
+ """
244
+ Delete a gift card template.
245
+
246
+ Args:
247
+ template_id (str): Template ID to delete
248
+ merchant_id (str): Merchant ID for security
249
+
250
+ Returns:
251
+ Dict[str, str]: Success message
252
+
253
+ Raises:
254
+ HTTPException: If deletion fails or template not found
255
+ """
256
+ try:
257
+ logger.info(f"Deleting template {template_id} for merchant {merchant_id}")
258
+
259
+ # First verify the template exists and belongs to the merchant
260
+ existing_template = await GiftCardRepository.get_template_by_id(template_id)
261
+ if not existing_template:
262
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
263
+
264
+ if existing_template.get('merchant_id') != merchant_id:
265
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
266
+
267
+ # Check if template can be deleted (business rule: no issued cards)
268
+ issued_count = existing_template.get('issued_count', 0)
269
+ if issued_count > 0:
270
+ raise HTTPException(
271
+ status_code=400,
272
+ detail=f"Cannot delete template with {issued_count} issued cards. Consider deactivating instead."
273
+ )
274
+
275
+ # Perform deletion
276
+ success = await GiftCardRepository.delete_template(template_id)
277
+
278
+ if not success:
279
+ raise HTTPException(status_code=500, detail=TEMPLATE_DELETE_FAILED)
280
+
281
+ return {"message": f"Template {template_id} deleted successfully"}
282
+
283
+ except HTTPException:
284
+ raise
285
+ except Exception as e:
286
+ logger.error(f"Service error deleting template {template_id}: {e}", exc_info=True)
287
+ raise HTTPException(status_code=500, detail=TEMPLATE_DELETE_FAILED)
288
+
289
+ @staticmethod
290
+ async def check_stock_availability(
291
+ template_id: str,
292
+ merchant_id: str
293
+ ) -> GiftCardStockResponse:
294
+ """
295
+ Check stock availability for a template.
296
+
297
+ Args:
298
+ template_id (str): Template ID to check
299
+ merchant_id (str): Merchant ID for security
300
+
301
+ Returns:
302
+ GiftCardStockResponse: Stock availability info
303
+
304
+ Raises:
305
+ HTTPException: If template not found or access denied
306
+ """
307
+ try:
308
+ logger.info(f"Checking stock for template {template_id}")
309
+
310
+ # First verify the template exists and belongs to the merchant
311
+ existing_template = await GiftCardRepository.get_template_by_id(template_id)
312
+ if not existing_template:
313
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
314
+
315
+ if existing_template.get('merchant_id') != merchant_id:
316
+ raise HTTPException(status_code=404, detail=TEMPLATE_NOT_FOUND)
317
+
318
+ # Get stock information
319
+ stock_info = await GiftCardRepository.check_stock_availability(template_id)
320
+
321
+ return GiftCardStockResponse(**stock_info)
322
+
323
+ except HTTPException:
324
+ raise
325
+ except Exception as e:
326
+ logger.error(f"Service error checking stock for template {template_id}: {e}", exc_info=True)
327
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
328
+
329
+ @staticmethod
330
+ async def get_active_templates(merchant_id: str) -> List[GiftCardTemplateResponse]:
331
+ """
332
+ Get active templates for a merchant.
333
+
334
+ Args:
335
+ merchant_id (str): Merchant ID
336
+
337
+ Returns:
338
+ List[GiftCardTemplateResponse]: List of active templates
339
+ """
340
+ try:
341
+ templates_data = await GiftCardRepository.get_active_templates(merchant_id)
342
+
343
+ templates = []
344
+ for template_data in templates_data:
345
+ try:
346
+ templates.append(GiftCardTemplateResponse(**template_data))
347
+ except ValidationError as ve:
348
+ logger.warning(f"Failed to parse active template: {template_data}, error: {ve}")
349
+ continue
350
+
351
+ return templates
352
+
353
+ except Exception as e:
354
+ logger.error(f"Service error getting active templates: {e}", exc_info=True)
355
+ raise HTTPException(status_code=500, detail=INTERNAL_SERVER_ERROR)
356
+
357
+ @staticmethod
358
+ async def _validate_template_update(
359
+ existing_template: Dict[str, Any],
360
+ update_data: Dict[str, Any]
361
+ ) -> None:
362
+ """
363
+ Validate business rules for template updates.
364
+
365
+ Args:
366
+ existing_template (Dict[str, Any]): Current template data
367
+ update_data (Dict[str, Any]): Proposed updates
368
+
369
+ Raises:
370
+ HTTPException: If validation fails
371
+ """
372
+ # Check if trying to change delivery_type
373
+ if 'delivery_type' in update_data:
374
+ current_delivery_type = existing_template.get('delivery_type')
375
+ new_delivery_type = update_data['delivery_type']
376
+
377
+ if current_delivery_type != new_delivery_type:
378
+ raise HTTPException(
379
+ status_code=400,
380
+ detail="Cannot change delivery_type of an existing template"
381
+ )
382
+
383
+ # Check physical card constraints
384
+ if existing_template.get('delivery_type') == 'physical':
385
+ if 'reloadable' in update_data and update_data['reloadable']:
386
+ raise HTTPException(
387
+ status_code=400,
388
+ detail="Physical cards cannot be made reloadable"
389
+ )
390
+
391
+ if 'max_issues' in update_data and update_data['max_issues'] is None:
392
+ raise HTTPException(
393
+ status_code=400,
394
+ detail="Physical cards must have max_issues specified"
395
+ )
396
+
397
+ # Check if trying to reduce max_issues below issued_count
398
+ if 'max_issues' in update_data:
399
+ issued_count = existing_template.get('issued_count', 0)
400
+ new_max_issues = update_data['max_issues']
401
+
402
+ if new_max_issues is not None and new_max_issues < issued_count:
403
+ raise HTTPException(
404
+ status_code=400,
405
+ detail=f"Cannot set max_issues ({new_max_issues}) below issued_count ({issued_count})"
406
+ )
test_gift_card_implementation.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Test script for Gift Card Template API endpoints.
4
+ This script demonstrates the usage of the gift card template endpoints.
5
+ """
6
+
7
+ import asyncio
8
+ import sys
9
+ import os
10
+
11
+ # Add the app directory to Python path
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
13
+
14
+ from app.schemas.gift_card_schema import (
15
+ GiftCardTemplateCreate,
16
+ GiftCardTemplateUpdate,
17
+ DesignTemplate
18
+ )
19
+
20
+ def test_gift_card_schemas():
21
+ """Test gift card schema validation."""
22
+ print("Testing Gift Card Template Schemas...")
23
+
24
+ # Test creating a digital gift card template
25
+ try:
26
+ digital_template = GiftCardTemplateCreate(
27
+ name="Digital Birthday Card",
28
+ description="Special digital card for birthdays",
29
+ delivery_type="digital",
30
+ currency="INR",
31
+ validity_days=365,
32
+ allow_custom_amount=True,
33
+ predefined_amounts=[500, 1000, 2000],
34
+ reloadable=False,
35
+ allow_partial_redemption=True,
36
+ requires_pin=True,
37
+ requires_otp=False,
38
+ status="active",
39
+ design_template=DesignTemplate(
40
+ theme="blue",
41
+ image_url="https://example.com/birthday.png"
42
+ ),
43
+ created_by="admin_123"
44
+ )
45
+ print("βœ“ Digital template schema validation passed")
46
+ print(f" Template name: {digital_template.name}")
47
+ print(f" Delivery type: {digital_template.delivery_type}")
48
+ print(f" Currency: {digital_template.currency}")
49
+ except Exception as e:
50
+ print(f"βœ— Digital template schema validation failed: {e}")
51
+ return False
52
+
53
+ # Test creating a physical gift card template
54
+ try:
55
+ physical_template = GiftCardTemplateCreate(
56
+ name="Physical Premium β‚Ή1000",
57
+ description="Premium physical plastic card with fixed denomination",
58
+ delivery_type="physical",
59
+ currency="INR",
60
+ validity_days=730,
61
+ allow_custom_amount=False,
62
+ predefined_amounts=[1000],
63
+ reloadable=False,
64
+ allow_partial_redemption=True,
65
+ requires_pin=True,
66
+ requires_otp=False,
67
+ status="active",
68
+ max_issues=500,
69
+ design_template=DesignTemplate(
70
+ theme="gold",
71
+ image_url="https://example.com/physical.png"
72
+ ),
73
+ branch_ids=["branch_1", "branch_2"],
74
+ campaign_id="campaign_holiday_2025",
75
+ metadata={"batch": "2025_holiday"},
76
+ created_by="marketing_mgr"
77
+ )
78
+ print("βœ“ Physical template schema validation passed")
79
+ print(f" Template name: {physical_template.name}")
80
+ print(f" Max issues: {physical_template.max_issues}")
81
+ print(f" Branch IDs: {physical_template.branch_ids}")
82
+ except Exception as e:
83
+ print(f"βœ— Physical template schema validation failed: {e}")
84
+ return False
85
+
86
+ # Test update schema
87
+ try:
88
+ update_template = GiftCardTemplateUpdate(
89
+ status="inactive",
90
+ predefined_amounts=[1000, 2000]
91
+ )
92
+ print("βœ“ Update template schema validation passed")
93
+ print(f" Status update: {update_template.status}")
94
+ print(f" Amounts update: {update_template.predefined_amounts}")
95
+ except Exception as e:
96
+ print(f"βœ— Update template schema validation failed: {e}")
97
+ return False
98
+
99
+ # Test validation errors
100
+ try:
101
+ # This should fail - physical card without max_issues
102
+ GiftCardTemplateCreate(
103
+ name="Invalid Physical Card",
104
+ delivery_type="physical",
105
+ currency="INR",
106
+ allow_custom_amount=True,
107
+ reloadable=False,
108
+ allow_partial_redemption=True,
109
+ requires_pin=True,
110
+ requires_otp=False,
111
+ status="active",
112
+ created_by="admin_123"
113
+ )
114
+ print("βœ— Validation error test failed - should have caught missing max_issues")
115
+ return False
116
+ except ValueError as e:
117
+ print("βœ“ Validation error correctly caught:", str(e))
118
+
119
+ try:
120
+ # This should fail - no amounts configuration
121
+ GiftCardTemplateCreate(
122
+ name="Invalid Amounts Card",
123
+ delivery_type="digital",
124
+ currency="INR",
125
+ allow_custom_amount=False,
126
+ reloadable=False,
127
+ allow_partial_redemption=True,
128
+ requires_pin=True,
129
+ requires_otp=False,
130
+ status="active",
131
+ created_by="admin_123"
132
+ )
133
+ print("βœ— Validation error test failed - should have caught missing amounts config")
134
+ return False
135
+ except ValueError as e:
136
+ print("βœ“ Validation error correctly caught:", str(e))
137
+
138
+ return True
139
+
140
+ def test_models_import():
141
+ """Test that all models can be imported successfully."""
142
+ try:
143
+ from app.models.gift_card_models import GiftCardTemplateModel
144
+ from app.repositories.gift_card_repository import GiftCardRepository
145
+ from app.services.gift_card_service import GiftCardService
146
+ from app.routers.gift_card_router import router
147
+
148
+ print("βœ“ All modules imported successfully")
149
+ print(f" Model: {GiftCardTemplateModel.__name__}")
150
+ print(f" Repository: {GiftCardRepository.__name__}")
151
+ print(f" Service: {GiftCardService.__name__}")
152
+ print(f" Router: {router.__class__.__name__}")
153
+ return True
154
+ except ImportError as e:
155
+ print(f"βœ— Import error: {e}")
156
+ return False
157
+
158
+ def main():
159
+ """Run all tests."""
160
+ print("🎯 Gift Card Template Implementation Test\n")
161
+
162
+ # Test imports
163
+ print("1. Testing module imports...")
164
+ import_success = test_models_import()
165
+ print()
166
+
167
+ # Test schemas
168
+ print("2. Testing schema validation...")
169
+ schema_success = test_gift_card_schemas()
170
+ print()
171
+
172
+ # Summary
173
+ print("πŸ“Š Test Summary:")
174
+ print(f" Module imports: {'βœ“ PASS' if import_success else 'βœ— FAIL'}")
175
+ print(f" Schema validation: {'βœ“ PASS' if schema_success else 'βœ— FAIL'}")
176
+
177
+ if import_success and schema_success:
178
+ print("\nπŸŽ‰ All tests passed! Gift Card Template implementation is working correctly.")
179
+ print("\nπŸ“š Available endpoints:")
180
+ print(" POST /api/v1/giftcard/ - Create gift card template")
181
+ print(" GET /api/v1/giftcard/ - List gift card templates")
182
+ print(" GET /api/v1/giftcard/{id} - Get specific template")
183
+ print(" PUT /api/v1/giftcard/{id} - Update template")
184
+ print(" DELETE /api/v1/giftcard/{id} - Delete template")
185
+ print(" GET /api/v1/giftcard/{id}/stock - Check stock availability")
186
+ print(" GET /api/v1/giftcard/active/list - Get active templates")
187
+ return True
188
+ else:
189
+ print("\n❌ Some tests failed. Please check the implementation.")
190
+ return False
191
+
192
+ if __name__ == "__main__":
193
+ main()