Transactions Domain - Views Documentation
Complete documentation of Transactions domain API endpoints and ViewSets.
Last Updated: 2026-07-08
Sources:
api/nextango/apps/transactions/views.pyapi/nextango/apps/transactions/viewsets/sale_line_item_viewset.pyapi/nextango/apps/transactions/viewsets/return_viewset.pyapi/nextango/apps/transactions/viewsets/return_line_item_viewset.pyapi/nextango/apps/transactions/viewsets/fulfillment_viewset.py
Table of Contents
- Overview
- TransactionViewSet
- SaleLineItemViewSet
- ReturnViewSet
- ReturnLineItemViewSet
- FulfillmentViewSet
- ShipmentViewSet
- PaymentMethodViewSet
- CashDrawerViewSet
- Development Views
Overview
The Transactions views provide RESTful API endpoints for sales, returns, payments, fulfillment tracking, and cash drawer operations.
ViewSets:
- TransactionViewSet: Sales transaction management, including transaction-level analytics
- SaleLineItemViewSet: Line item CRUD and queries
- ReturnViewSet: Return processing and management
- ReturnLineItemViewSet: Return line item queries (read-only)
- FulfillmentViewSet: Customer order tracking (read-only +
my_orders) - ShipmentViewSet: Shipment tracking (read-only)
- PaymentMethodViewSet: Payment method queries (read-only +
available) - CashDrawerViewSet: Cash drawer operations
- DevTransactionListView: Development-only browsing
Key Patterns:
- Service Layer Delegation: Business logic delegated to service classes
- Validation Separation: Validation delegated to validation services
- Security Logging: All financial operations logged via SecurityEventLogger
- Exception Handling: @handle_view_exception decorator for consistent error responses
- Authentication: PosGatewayAuthentication + WebSessionAuthentication dual auth
- Customer Order Tracking: WebSessionAuthentication for guest/customer order access
URL mounting: nextango/urls.py:144 mounts path('transactions/', include('nextango.apps.transactions.urls')) ahead of the root router (nextango/urls.py:152), so this app's own urls.py owns everything under /transactions/: the analytics actions below, plus router-registered line-items, returns, return-line-items, sale-transactions, fulfillments, shipments, and pickup (transactions/urls.py:20-27). The root router (nextango/urls.py:31-39) separately registers TransactionViewSet at top-level /transactions/ (detail routes like /transactions/{transactionId}/), plus top-level /sale-transactions/, /returns/, /return-line-items/, /sale-line-items/, and /cash-drawer/. The two returns/sale-transactions registrations are reachable at both the top-level path and the /transactions/-prefixed path; examples in this page use the top-level form the older client code paths use, except where noted.
No StoreCredit endpoints exist. StoreCredit has a model and a StoreCreditSerializer (see Models and Serializers) but no registered ViewSet and no URL: there is no /store-credits/ route anywhere in nextango/urls.py or transactions/urls.py. Store credit records are created and read only through ReturnService (see Services).
TransactionViewSet
Purpose: Main ViewSet for sales transaction management.
Source: views.py:43-481
Base Class: viewsets.ModelViewSet
Configuration:
queryset = SaleTransaction.objects.all()
serializer_class = SaleTransactionSerializer
lookup_field = 'transactionId'
authentication_classes = [PosGatewayAuthentication, WebSessionAuthentication]
permission_classes = [IsAuthenticated]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['transactionId', 'customer__user__name']
ordering_fields = ['created_at', 'totalAmount']
ordering = ['-created_at']
Service Initialization
Source: views.py:54-58
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.transaction_service = TransactionService()
self.payment_service = PaymentService()
self.validation_service = TransactionValidationService()
Services Used:
TransactionService: Transaction business logicPaymentService: Payment processingTransactionValidationService: Request validation
Queryset Optimization
Source: views.py:60-74
def get_queryset(self):
queryset = super().get_queryset()
# Filter by status
status_param = self.request.query_params.get('status')
if status_param:
queryset = queryset.filter(status=status_param)
# Filter by store (accepts either Django UUID or Sanity ID)
store_param = self.request.query_params.get('store')
if store_param:
# Try filtering by Django ID first, then fall back to sanity_id
queryset = queryset.filter(Q(store__id=store_param) | Q(store__sanity_id=store_param))
return queryset.select_related('store', 'customer').prefetch_related('line_items')
Query Parameters:
status: Filter by transaction status (pending, completed, etc.)store: Filter by store (accepts Django UUID or Sanity ID)
Optimization:
select_related('store', 'customer'): Reduces N+1 queriesprefetch_related('line_items'): Efficiently loads line items
Standard CRUD Operations
List Transactions
Endpoint: GET /transactions/
Query Parameters:
status: Filter by statusstore: Filter by storesearch: Search by transactionId or customer nameordering: Order by created_at or totalAmountpage: Page numberpage_size: Results per page
Response: Paginated list of transactions with nested line items, payment records, and return records.
Retrieve Transaction
Endpoint: GET /transactions/{transactionId}/
Parameters:
transactionId: Transaction ID (e.g., TXN-20251117-A3B4C5D6)
Response: Full transaction details including:
- Line items with product snapshots
- Payment records
- Return records
- Computed properties (payment_balance, is_fully_paid, refundable_amount, can_be_returned)
Create Transaction
Endpoint: POST /transactions/
Source: views.py:80-234
Security Note: Financial totals (subtotal, taxAmount, totalAmount) are IGNORED. Backend recalculates all values server-side.
Implementation Flow:
- Authentication & Store Resolution:
# Extract store from JWT session (POS Gateway authentication)
if hasattr(request, 'auth') and request.auth:
store_id = request.auth.get('store_id')
if store_id:
store = Store.objects.get(sanity_id=store_id)
logger.info(f"Using store from JWT session: {store.name}")
# Fallback to provided store_id
if not store and validated_data.get('store_id'):
store = Store.objects.get(id=validated_data['store_id'])
- Employee Resolution:
# Try Employee record first
try:
employee_record = Employee.objects.get(id=validated_data['employee_id'])
employee_user = employee_record.user
except Employee.DoesNotExist:
# Fall back to Manager record
manager_record = Manager.objects.get(id=validated_data['employee_id'])
employee_user = manager_record.user
- Transaction Creation with Sync Disabled:
from nextango.apps.integrations.tasks.signal_management import disable_sync_signals
with disable_sync_signals():
# Create transaction
transaction = self.transaction_service.create_transaction(
store=store,
employee=employee_user,
customer=customer
)
# Add line items (batched to prevent duplicate sync triggers)
for item_data in line_items:
self.transaction_service.add_product_to_transaction_without_recalc(
transaction_id=transaction.transactionId,
product_variant_id=item_data.get('productVariant'),
quantity=item_data.get('quantity', 1),
unit_price=item_data.get('unitPrice') # Optional - uses variant price if None
)
# Apply promo codes
for promo_code_str in applied_promo_codes:
promo_code = PromoCode.objects.get(code=promo_code_str.upper(), isActive=True)
transaction.appliedPromoCodes.add(promo_code)
- Server-Side Total Calculation:
# CALCULATE ALL TOTALS SERVER-SIDE (subtotal, tax, discounts, total)
# This is the ONLY source of financial calculations - never trust client
if line_items:
transaction.recalculate_totals(commit=True)
logger.info(
f"Calculated transaction totals - "
f"{transaction.transactionId}: "
f"Subtotal \${transaction.subtotal}, "
f"Tax \${transaction.taxAmount}, "
f"Discount \${transaction.orderLevelDiscount}, "
f"Total \${transaction.totalAmount}"
)
Request Body:
{
"storeId": "uuid-here",
"employeeId": "uuid-here",
"customerId": "uuid-here",
"line_items": [
{
"productVariant": "variant-uuid",
"quantity": 2,
"unitPrice": "29.99"
}
],
"appliedPromoCodes": ["SPRING20"],
"subtotal": 0, // IGNORED by backend
"taxAmount": 0, // IGNORED by backend
"totalAmount": 0 // IGNORED by backend
}
Response: Created transaction with server-calculated totals.
Security: Financial fields from client are ignored; all calculations performed server-side.
Custom Actions: URL Parameter Name
TransactionViewSet uses lookup_field = 'transactionId' (camelCase). All 8 custom @action methods receive the URL path parameter as transactionId=None, matching lookup_field. Using the wrong parameter name (e.g., transaction_id=None) was a silent bug: DRF routed the request correctly but the parameter variable was always None instead of the actual transaction ID string.
Fixed actions (all now use transactionId=None):
| Action | HTTP Method | URL Pattern |
|---|---|---|
add_item | POST | /{transactionId}/add_item/ |
remove_item | DELETE | /{transactionId}/remove_item/ |
update_quantity | PATCH | /{transactionId}/update_quantity/ |
apply_discount | POST | /{transactionId}/apply_discount/ |
apply_promo_code | POST | /{transactionId}/apply_promo_code/ |
remove_promo_code | POST | /{transactionId}/remove_promo_code/ |
process_payment | POST | /{transactionId}/process_payment/ |
void | POST | /{transactionId}/void/ |
The fix had no effect on routing (DRF uses self.get_object() which reads lookup_field from the URL kwargs directly). The fix ensures code correctness and prevents future bugs when the parameter variable is passed explicitly to service calls.
Add Item (POST /{transactionId}/add_item/)
Source: views.py:236-253
Purpose: Add a product to an existing transaction.
Request Body:
{
"product_variant_id": "uuid-here",
"quantity": 2,
"unit_price": "29.99" // Optional - uses variant price if not provided
}
Implementation:
@action(detail=True, methods=['post'])
@handle_view_exception
def add_item(self, request, transactionId=None):
transaction = self.get_object()
serializer = AddSaleLineItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
self.validation_service.validate_add_item_request(validated_data, transaction, request)
try:
updated_transaction = self.transaction_service.add_product_to_transaction(
transaction_id=transaction.transactionId, **validated_data
)
response_serializer = self.get_serializer(updated_transaction)
return Response(response_serializer.data, status=status.HTTP_200_OK)
except TransactionServiceError as e:
raise BusinessLogicException(str(e))
Response: Updated transaction with recalculated totals.
Remove Item (DELETE /{transactionId}/remove_item/)
Source: views.py:255-268
Purpose: Remove a line item from transaction.
Request Body:
{
"line_item_id": "uuid-here"
}
Response: Updated transaction with recalculated totals.
Update Quantity (PATCH /{transactionId}/update_quantity/)
Source: views.py:270-286
Purpose: Update quantity of an existing line item.
Request Body:
{
"line_item_id": "uuid-here",
"quantity": 5
}
Response: Updated transaction with recalculated totals.
Apply Discount (POST /{transactionId}/apply_discount/)
Source: views.py:288-307
Purpose: Apply manual discount to a line item.
Request Body:
{
"line_item_id": "uuid-here",
"discount_amount": "5.00"
}
Authorization: May require manager override depending on discount amount.
Response: Updated transaction with recalculated totals.
Apply Promo Code (POST /{transactionId}/apply_promo_code/)
Source: views.py:309-336
Purpose: Apply a promotional code to the transaction using PromotionService.
Request Body:
{
"promo_code": "SPRING20"
}
Implementation:
@action(detail=True, methods=['post'])
@handle_view_exception
def apply_promo_code(self, request, transactionId=None):
transaction = self.get_object()
validated_data = self.validation_service.validate_promo_code_request(
request.data, transaction, request
)
try:
updated_transaction = self.transaction_service.apply_promo_code(
transaction_id=transactionId,
promo_code=validated_data['promo_code'],
store_id=str(transaction.store.id) if transaction.store else None
)
response_serializer = self.get_serializer(updated_transaction)
return Response(response_serializer.data, status=status.HTTP_200_OK)
except TransactionServiceError as e:
raise BusinessLogicException(str(e))
Integration: Uses PromotionService for comprehensive promotional engine integration.
Response: Updated transaction with promotional discounts applied.
Remove Promo Code (POST /{transactionId}/remove_promo_code/)
Source: views.py:338-358
Purpose: Remove a promo code from the transaction.
Request Body:
{
"promo_code": "SPRING20"
}
Response: Updated transaction with promo code removed and totals recalculated.
Promotional Summary (GET /{transactionId}/promotional_summary/)
Source: views.py:360-400
Purpose: Get detailed breakdown of all promotional discounts applied.
Response:
{
"transaction_id": "TXN-20251117-A3B4C5D6",
"order_level_discount": 10.00,
"applied_promo_codes": [
{
"code": "SPRING20",
"name": "Spring Sale 2025",
"discount_type": "percentage"
}
],
"line_item_promotions": [
{
"line_item_id": "uuid-here",
"product_name": "Widget Pro",
"sku": "WIDG-001",
"promotions": [
{
"promo_code": "SPRING20",
"campaign_name": "Spring Sale 2025",
"discount_amount": "5.99",
"discount_type": "percentage",
"applied_at": "2025-11-17T10:30:00Z"
}
]
}
]
}
Use Cases: Analytics, customer transparency, receipt printing.
Process Payment (POST /{transactionId}/process_payment/)
Source: views.py:402-423
Purpose: Process payment for a transaction.
Request Body:
{
"payment_method_id": 1,
"amount": "59.99", // Optional - uses transaction total if not provided
"tendered_amount": "60.00" // Optional - for change calculation
}
Implementation:
@action(detail=True, methods=['post'])
@handle_view_exception
def process_payment(self, request, transactionId=None):
transaction = self.get_object()
serializer = ProcessPaymentRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
self.validation_service.validate_payment_request(validated_data, transaction, request)
try:
payment = self.transaction_service.process_payment(
transaction_id=transactionId, **validated_data
)
payment_serializer = TransactionPaymentSerializer(payment)
return Response(payment_serializer.data, status=status.HTTP_201_CREATED)
except (TransactionServiceError, PaymentProcessingError) as e:
SecurityEventLogger.log_payment_security_event(
'payment_processing_failure', transactionId, f"Payment failed: {str(e)}",
request, {'payment_amount': str(validated_data['amount'])}
)
raise PaymentSecurityException("Payment processing failed")
Security Logging: Payment failures are logged via SecurityEventLogger.
Response: Created payment record with processor response.
Complete (POST /{transactionId}/complete/)
Source: views.py:425-438
Purpose: Mark transaction as completed.
Response: Updated transaction with status='completed', completedAt timestamp set.
Security Logging: Transaction completion logged for audit trail.
Void (POST /{transactionId}/void/)
Source: views.py:440-460
Purpose: Void a transaction (typically requires manager approval).
Request Body:
{
"reason": "Customer cancelled order",
"manager_override": true
}
Validation: Void request validated by validation service (may require manager override).
Response: Updated transaction with status='voided', voidReason set.
Details (GET /{transactionId}/details/)
Source: views.py:462-470
Purpose: Get detailed transaction information from TransactionService.
Response: Comprehensive transaction details (format defined by TransactionService).
Payment Summary (GET /{transactionId}/payment_summary/)
Source: views.py:472-480
Purpose: Get payment summary from PaymentService.
Response:
{
"total_amount": "59.99",
"paid_amount": "59.99",
"balance": "0.00",
"payments": [
{
"amount": "59.99",
"method": "credit_card",
"status": "succeeded",
"processed_at": "2025-11-17T10:30:00Z"
}
]
}
Analytics Endpoints
Source: views.py:562-677, bound directly in transactions/urls.py:34-50 ahead of the router include so their slashed url_path values (analytics/summary, etc.) are not shadowed.
Authorization: permission_classes=[IsAuthenticated, IsEmployeeOrManager] set per-action, overriding the ViewSet-level IsAuthenticated. Staff-only; not reachable by customer or guest sessions.
| Endpoint | Purpose |
|---|---|
GET /transactions/analytics/summary/ | Aggregate revenue, tax, discount, and order-count summary for a store/date range |
GET /transactions/analytics/over-time/ | Revenue and order-count series bucketed over time |
GET /transactions/analytics/by-source/ | Breakdown by source_system (pos, web_commerce, dashboard, api) |
GET /transactions/analytics/by-fulfillment/ | Breakdown by fulfillmentMethod |
GET /transactions/analytics/by-payment-method/ | Breakdown by payment method |
GET /transactions/analytics/fulfillment/ | Fulfillment-operations metrics |
GET /transactions/analytics/returns/ | Return-rate and refund-value metrics |
SaleLineItemViewSet
Purpose: ViewSet for sale line item management.
Source: viewsets/sale_line_item_viewset.py
Base Class: viewsets.ModelViewSet
Configuration:
queryset = SaleLineItem.objects.all()
serializer_class = SaleLineItemSerializer
authentication_classes = [PosGatewayAuthentication, WebSessionAuthentication]
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['skuSnapshot', 'nameSnapshot', 'transaction__transactionId']
ordering_fields = ['created_at', 'unitPrice', 'quantity']
ordering = ['-created_at']
Custom Filters
Source: viewsets/sale_line_item_viewset.py:30-59
class SaleLineItemFilter(django_filters.FilterSet):
transaction_id = django_filters.CharFilter(
field_name='transaction__transactionId',
lookup_expr='icontains'
)
sku = django_filters.CharFilter(
field_name='skuSnapshot',
lookup_expr='icontains'
)
product_name = django_filters.CharFilter(
field_name='nameSnapshot',
lookup_expr='icontains'
)
min_quantity = django_filters.NumberFilter(field_name='quantity', lookup_expr='gte')
max_quantity = django_filters.NumberFilter(field_name='quantity', lookup_expr='lte')
min_price = django_filters.NumberFilter(field_name='unitPrice', lookup_expr='gte')
max_price = django_filters.NumberFilter(field_name='unitPrice', lookup_expr='lte')
date_from = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
date_to = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
Available Filters:
transaction_id: Filter by transaction ID (partial match)sku: Filter by SKU (partial match)product_name: Filter by product name (partial match)min_quantity,max_quantity: Quantity range filtersmin_price,max_price: Price range filtersdate_from,date_to: Date range filters
Queryset Optimization
Source: viewsets/sale_line_item_viewset.py:61-68
def get_queryset(self):
"""Optimize queryset with select_related."""
return SaleLineItem.objects.select_related(
'transaction',
'transaction__store',
'productVariant',
'productVariant__product'
)
Optimization: Reduces N+1 queries for transaction, store, variant, and product relationships.
Custom Actions
By Transaction (GET /sale-line-items/by_transaction/)
Source: viewsets/sale_line_item_viewset.py:70-102
Purpose: Get all line items for a specific transaction.
Query Parameters:
transaction_id: Transaction ID (required)
Request:
GET /sale-line-items/by_transaction/?transaction_id=TXN-20251117-A3B4C5D6
Response: Array of line items for the transaction.
By Product (GET /sale-line-items/by_product/)
Source: viewsets/sale_line_item_viewset.py:104-130
Purpose: Get all line items for a specific product variant.
Query Parameters:
variant_id: Product variant UUID (required)
Request:
GET /sale-line-items/by_product/?variant_id=uuid-here
Response: Array of all line items for this product variant (sales history).
Use Cases: Product sales analytics, inventory tracking.
ReturnViewSet
Purpose: ViewSet for return processing and management.
Source: viewsets/return_viewset.py
Base Class: viewsets.ModelViewSet
Configuration:
queryset = Return.objects.all()
serializer_class = ReturnSerializer
authentication_classes = [PosGatewayAuthentication, WebSessionAuthentication]
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['returnId', 'reason', 'originalSaleId__transactionId']
ordering_fields = ['created_at', 'status', 'returnId']
ordering = ['-created_at']
Custom Filters
Source: viewsets/return_viewset.py:32-50
class ReturnFilter(django_filters.FilterSet):
original_transaction_id = django_filters.CharFilter(
field_name='originalSaleId__transactionId',
lookup_expr='icontains'
)
status = django_filters.ChoiceFilter(choices=Return.Status.choices)
date_from = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
date_to = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
employee = django_filters.NumberFilter(field_name='employeeId')
Queryset Optimization
Source: viewsets/return_viewset.py:52-59
def get_queryset(self):
"""Optimize queryset with select_related."""
return Return.objects.select_related(
'originalSaleId',
'originalSaleId__store',
'employeeId',
'processedBy'
).prefetch_related('return_line_items', 'refunds')
Custom Actions
Process Return (POST /returns/process_return/)
Source: viewsets/return_viewset.py:61-144
Purpose: Process a complete return with items and refund/credit processing.
Request Body:
{
"original_transaction_id": "TXN-20251117-A3B4C5D6",
"return_items": [
{
"transaction_item_id": "line-item-uuid",
"quantity": 2,
"reason": "changed_mind",
"disposition": "sellable"
}
],
"reason": "Customer changed their mind",
"refund_method": "original",
"refund_to_payment_ids": ["payment-uuid"]
}
Valid Values:
- reason (item): damaged, wrong_size, unwanted_gift, defective, not_as_described, changed_mind
- disposition: sellable, defective, quarantine, damaged_out, vendor_return
- refund_method: original, store_credit
Implementation Flow:
- Validation:
required_fields = ['original_transaction_id', 'return_items', 'reason', 'refund_method']
for field in required_fields:
if field not in request.data:
return Response({'error': f'Missing required field: {field}'}, status=400)
- Transaction Lookup:
original_transaction = SaleTransaction.objects.get(
transactionId=request.data['original_transaction_id']
)
- Employee Resolution:
employee = request.user # Use authenticated user directly (POS terminals)
- Return Processing via ReturnService:
return_service = ReturnService()
# Handle both single payment ID (legacy) and multiple payment IDs
refund_to_payment_ids = request.data.get('refund_to_payment_ids')
if not refund_to_payment_ids and request.data.get('refund_to_payment_id'):
refund_to_payment_ids = [request.data.get('refund_to_payment_id')]
return_record = return_service.process_return(
original_transaction=original_transaction,
return_items_data=request.data['return_items'],
reason=request.data['reason'],
employee=employee,
refund_method=request.data['refund_method'],
refund_to_payment_ids=refund_to_payment_ids
)
Response: Created return record with nested line items and refunds.
Start Return (POST /returns/start_return/)
Source: viewsets/return_viewset.py:146-196
Purpose: Mark a transaction as having a return in progress (sets status to partially_refunded).
Request Body:
{
"transaction_id": "TXN-20251117-A3B4C5D6"
}
Behavior:
- Only allowed on transactions with status='completed'
- Sets transaction status to 'partially_refunded' (API-only, not synced to Sanity)
- Used to lock transaction during return item selection
Response:
{
"transaction_id": "TXN-20251117-A3B4C5D6",
"status": "partially_refunded",
"message": "Return started successfully"
}
Cancel Return In Progress (POST /returns/cancel_return_in_progress/)
Source: viewsets/return_viewset.py:198-248
Purpose: Cancel a return in progress and reset transaction to completed.
Request Body:
{
"transaction_id": "TXN-20251117-A3B4C5D6"
}
Behavior:
- Only allowed on transactions with status='partially_refunded'
- Resets transaction status to 'completed'
- Does NOT sync to Sanity (just reverting API-only state)
Response:
{
"transaction_id": "TXN-20251117-A3B4C5D6",
"status": "completed",
"message": "Return cancelled successfully"
}
Cancel Return (POST /returns/{pk}/cancel_return/)
Source: viewsets/return_viewset.py:250-273
Purpose: Cancel a pending return record.
Requirements: Return must have status='pending'
Response: Updated return record with status='cancelled'.
Return Summary (GET /returns/{pk}/return_summary/)
Source: viewsets/return_viewset.py:275-321
Purpose: Get detailed return summary including items and refunds.
Response:
{
"return_id": "RET-20251117-F7E8D9C0",
"original_transaction": "TXN-20251117-A3B4C5D6",
"status": "processed",
"reason": "Customer changed their mind",
"created_at": "2025-11-17T10:30:00Z",
"employee": "John Doe",
"processed_by": "Jane Manager",
"customer": "Alice Smith",
"items": [
{
"product_name": "Widget Pro",
"sku": "WIDG-001",
"quantity": 2,
"return_price": "29.99",
"reason": "changed_mind",
"disposition": "sellable"
}
],
"refunds": [
{
"refund_id": "REF-20251117-C1D2E3F4",
"amount": "59.98",
"status": "succeeded",
"processed_at": "2025-11-17T10:35:00Z",
"payment_method": "Credit Card"
}
],
"totals": {
"items_count": 2,
"total_return_value": "59.98",
"total_refund_amount": "59.98"
}
}
ReturnLineItemViewSet
Purpose: Read-only ViewSet for return line item queries.
Source: viewsets/return_line_item_viewset.py
Base Class: viewsets.ReadOnlyModelViewSet
Return line items are created through ReturnService, not directly via API.
Configuration:
queryset = ReturnLineItem.objects.all()
serializer_class = ReturnLineItemSerializer
authentication_classes = [PosGatewayAuthentication, WebSessionAuthentication]
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['skuSnapshot', 'nameSnapshot', 'sales_return__returnId']
ordering_fields = ['created_at', 'returnPrice', 'quantity']
ordering = ['-created_at']
Custom Filters
Source: viewsets/return_line_item_viewset.py:31-50
class ReturnLineItemFilter(django_filters.FilterSet):
sales_return = django_filters.UUIDFilter(field_name='sales_return__id')
return_id = django_filters.CharFilter(
field_name='sales_return__returnId',
lookup_expr='icontains'
)
reason = django_filters.ChoiceFilter(choices=ReturnLineItem.ReturnReason.choices)
disposition = django_filters.ChoiceFilter(choices=ReturnLineItem.Disposition.choices)
date_from = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
date_to = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
Available Filters:
sales_return: Filter by return UUIDreturn_id: Filter by return ID (partial match)reason: Filter by return reason (choices)disposition: Filter by item disposition (choices)date_from,date_to: Date range filters
Queryset Optimization
Source: viewsets/return_line_item_viewset.py:52-59
def get_queryset(self):
"""Optimize queryset with select_related."""
return ReturnLineItem.objects.select_related(
'sales_return',
'sales_return__originalSaleId',
'original_sale_line_item',
'original_sale_line_item__productVariant'
).prefetch_related('reference_inventory_levels')
FulfillmentViewSet
Purpose: Customer-facing read-only ViewSet for order tracking. Provides access to fulfillment and shipment data scoped to the authenticated user.
Source: viewsets/fulfillment_viewset.py:28-113
Base Class: viewsets.ReadOnlyModelViewSet
Added: December 2025
Configuration:
serializer_class = FulfillmentSerializer
authentication_classes = [WebSessionAuthentication, CustomTokenAuthentication]
permission_classes = [AllowAny]
lookup_field = 'id'
Access Control
FulfillmentViewSet implements tiered access control:
| User Type | Access Level |
|---|---|
| Authenticated Customer | All fulfillments for their customer profile(s) |
| Guest User (with session) | Only fulfillment linked to their checkout session |
| No Authentication | Empty queryset (no access) |
Implementation:
def get_queryset(self):
user = self.request.user
if user.is_authenticated:
# Authenticated user: return all their fulfillments
customer_profiles = user.customer_roles.all()
if customer_profiles.exists():
return queryset.filter(transaction__customer__in=customer_profiles)
return Fulfillment.objects.none()
# Guest user: check for web session
web_session = self.request.auth
if web_session and hasattr(web_session, 'get'):
checkout_session_id = self.request.session.get('checkout_session_id')
if checkout_session_id:
return queryset.filter(transaction__sourceCheckoutId=checkout_session_id)
return Fulfillment.objects.none()
Endpoints
List Fulfillments (GET /transactions/fulfillments/)
Purpose: List user's fulfillments with shipment data.
Response: Paginated list of fulfillments with nested shipments and line items.
Retrieve Fulfillment (GET /transactions/fulfillments/{id}/)
Purpose: Get specific fulfillment details.
Parameters:
id: Fulfillment UUID
Response: Full fulfillment details including:
- Shipping address
- Billing address
- Delivery method
- Order totals snapshot
- Nested shipments with tracking info
My Orders (GET /transactions/fulfillments/my_orders/)
Source: fulfillment_viewset.py:93-113
Purpose: Get authenticated user's order history with fulfillment status.
Authorization: Requires authentication (403 for guests)
Response:
{
"count": 5,
"results": [
{
"id": "uuid-here",
"customer_email": "[email protected]",
"status": "shipped",
"shipping_city": "Denver",
"total_amount": "129.99",
"shipments": [
{
"carrier_name": "UPS",
"tracking_number": "1Z999AA10123456784",
"status": "in_transit",
"items": [
{
"sku": "WIDG-001",
"name": "Widget Pro",
"quantity_shipped": 2
}
]
}
]
}
]
}
ShipmentViewSet
Purpose: Read-only ViewSet for individual shipment tracking. Access controlled via parent fulfillment.
Source: viewsets/fulfillment_viewset.py:116-172
Base Class: viewsets.ReadOnlyModelViewSet
Added: December 2025
Configuration:
serializer_class = ShipmentSerializer
authentication_classes = [WebSessionAuthentication, CustomTokenAuthentication]
permission_classes = [AllowAny]
lookup_field = 'id'
Access Control
Same access control pattern as FulfillmentViewSet - scoped through parent fulfillment's transaction customer.
Endpoints
List Shipments (GET /transactions/shipments/)
Purpose: List user's shipments.
Response: Paginated list of shipments with carrier info and line items.
Retrieve Shipment (GET /transactions/shipments/{id}/)
Purpose: Get specific shipment details.
Parameters:
id: Shipment UUID
Response:
{
"id": "uuid-here",
"carrier_name": "UPS",
"tracking_number": "1Z999AA10123456784",
"status": "in_transit",
"status_display": "In Transit",
"shipped_at": "2025-12-07T14:30:00Z",
"delivered_at": null,
"items": [
{
"id": 1,
"line_item_id": "uuid-here",
"quantity_shipped": 2,
"sku": "WIDG-001",
"name": "Widget Pro"
}
],
"total_items": 2,
"created_at": "2025-12-07T10:00:00Z",
"updated_at": "2025-12-07T14:30:00Z"
}
PaymentMethodViewSet
Purpose: Read-only ViewSet for payment method queries.
Source: views.py:678-732
Base Class: viewsets.ReadOnlyModelViewSet
Configuration:
queryset = PaymentMethod.objects.filter(is_active=True)
serializer_class = PaymentMethodSerializer
Not currently reachable at /payment-methods/. The root URLconf's payment-methods router registration (nextango/urls.py:43) binds that path to nextango.apps.payments.views.PaymentMethodViewSet, a separate class in the payments app, not this one. This transactions-app PaymentMethodViewSet, including its available action documented below, is not registered in nextango/urls.py or transactions/urls.py and has no reachable route.
Only active payment methods are exposed via API.
Service Initialization
Source: views.py:487-489
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.payment_service = PaymentService()
Custom Actions
Available (GET /payment-methods/available/)
Source: views.py:491-506
Purpose: Get payment methods available for a specific context and amount.
Query Parameters:
type: Payment context (pos or online, default: pos)amount: Transaction amount (optional, for filtering by min/max amounts)
Implementation:
@action(detail=False, methods=['get'])
@handle_view_exception
def available(self, request):
"""Get available payment methods with validation delegated."""
serializer = AvailablePaymentMethodsSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
try:
methods = self.payment_service.get_available_payment_methods(
transaction_type=validated_data['type'], amount=validated_data.get('amount')
)
response_serializer = self.get_serializer(methods, many=True)
return Response(response_serializer.data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Get available payment methods failed: {str(e)}")
raise BusinessLogicException("Failed to get available payment methods")
Request:
GET /payment-methods/available/?type=pos&amount=59.99
Response: Array of payment methods available for POS with amount constraints satisfied.
CashDrawerViewSet
Purpose: ViewSet for cash drawer operations: POS register float management (open/close, mid-shift additions and removals, till counts).
Source: views.py:733-994
Base Class: viewsets.GenericViewSet
Cash drawer has no standard CRUD operations - all operations are custom actions.
Authorization: permission_classes = [IsAuthenticated, IsEmployeeOrManager] (views.py:778), staff-only. Every action either moves money-of-record (drawer floats, variances) or reads a store's drawer state.
Store binding (_resolve_drawer_store, views.py:788-817): a POS-terminal caller (request.auth is a pos_session JWT) is bound to the store and terminal it authenticated for. A register_id that does not match the session's terminal_id raises a 403 REGISTER_TERMINAL_MISMATCH. A web-session staff caller instead passes store_id in the request body, gated through user_store_gate() so a staff member cannot act on a store they are not assigned to (a 403 STORE_NOT_ASSIGNED if they are not).
Custom Actions
Open (POST /cash-drawer/open/)
Source: views.py:520-544
Purpose: Open cash drawer at shift start.
Request Body:
{
"store_id": "uuid-here",
"register_id": "REG-001",
"shift_id": "SHIFT-001",
"starting_amount": "200.00"
}
Security Logging: Drawer open events logged via SecurityEventLogger.
Response: Created CashDrawerEvent with eventType='open'.
Close (POST /cash-drawer/close/)
Source: views.py:546-570
Purpose: Close cash drawer at shift end with cash count.
Request Body:
{
"store_id": "uuid-here",
"register_id": "REG-001",
"shift_id": "SHIFT-001",
"ending_amount": "450.00",
"cash_breakdown": {
"hundreds": 4,
"twenties": 2,
"tens": 5,
"fives": 0,
"ones": 0
}
}
Variance Detection:
if abs(result.get('variance', 0)) > Decimal('10.00'):
SecurityEventLogger.log_suspicious_activity(
'cash_drawer_variance', f"Significant cash variance: \${result['variance']}",
request, 'warning', {'variance': str(result['variance'])}
)
Response:
{
"event": { /* CashDrawerEvent */ },
"variance": "-5.00", // Expected vs counted
"expected_amount": "455.00"
}
Security: Variances over $10.00 trigger security alerts.
Add Cash (POST /cash-drawer/add_cash/)
Source: views.py:594-614
Purpose: Add cash to drawer (pay-in operation).
Request Body:
{
"store_id": "uuid-here",
"register_id": "REG-001",
"amount": "100.00",
"reason": "Change fund replenishment",
"notes": "From safe"
}
Response: Created CashDrawerEvent with eventType='cash_in'.
Remove Cash (POST /cash-drawer/remove_cash/)
Source: views.py:616-640
Purpose: Remove cash from drawer (pay-out operation).
Request Body:
{
"store_id": "uuid-here",
"register_id": "REG-001",
"amount": "50.00",
"reason": "Vendor payment",
"notes": "Office supplies"
}
Security Logging: All cash removals logged as warnings via SecurityEventLogger.
Response: Created CashDrawerEvent with eventType='cash_out'.
Status (GET /cash-drawer/status/)
Source: views.py:642-657
Purpose: Get current cash drawer status.
Query Parameters:
store_id: Store UUID (required)register_id: Register ID (required)
Request:
GET /cash-drawer/status/?store_id=uuid-here®ister_id=REG-001
Response:
{
"is_open": true,
"current_amount": "325.00",
"opened_at": "2025-11-17T09:00:00Z",
"opened_by": "John Doe",
"transactions_count": 12,
"last_event": "sale"
}
Till Count (POST /cash-drawer/till_count/)
Source: views.py:659-678
Purpose: Record mid-shift till count.
Request Body:
{
"store_id": "uuid-here",
"register_id": "REG-001",
"counted_amount": "325.00",
"cash_breakdown": {
"hundreds": 3,
"twenties": 1,
"fives": 1
},
"notes": "Mid-shift count"
}
Response: Created CashDrawerEvent with eventType='till_count' and variance calculation.
Development Views
DevTransactionListView
Purpose: Development-only view to browse transactions without authentication.
Source: views.py:573-593, 681-700
Duplicate definition in code (lines 573-593 and 681-700 are identical).
Base Class: APIView
Configuration:
permission_classes = [AllowAny]
List Recent Transactions (GET /dev/transactions/)
Availability: Only when settings.DEBUG=True
Implementation:
def get(self, request):
if not settings.DEBUG:
return Response({'error': 'This endpoint is only available in development mode'},
status=status.HTTP_404_NOT_FOUND)
transactions = SaleTransaction.objects.all().order_by('-created_at')[:20]
serializer = SaleTransactionSerializer(transactions, many=True)
return Response({
'count': transactions.count(),
'results': serializer.data,
'message': 'Development view - showing latest 20 transactions'
})
Response: Latest 20 transactions with full details.
Security: Returns 404 in production (DEBUG=False).
Related Documentation
- Models - Transaction data models
- Serializers - API serialization
- Services - Business logic services
- Signals - Bidirectional sync with Sanity