Transactions Domain - Views Documentation

Complete documentation of Transactions domain API endpoints and ViewSets.

Last Updated: 2026-07-08

Sources:

  • api/nextango/apps/transactions/views.py
  • api/nextango/apps/transactions/viewsets/sale_line_item_viewset.py
  • api/nextango/apps/transactions/viewsets/return_viewset.py
  • api/nextango/apps/transactions/viewsets/return_line_item_viewset.py
  • api/nextango/apps/transactions/viewsets/fulfillment_viewset.py

Table of Contents

  1. Overview
  2. TransactionViewSet
  3. SaleLineItemViewSet
  4. ReturnViewSet
  5. ReturnLineItemViewSet
  6. FulfillmentViewSet
  7. ShipmentViewSet
  8. PaymentMethodViewSet
  9. CashDrawerViewSet
  10. 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 logic
  • PaymentService: Payment processing
  • TransactionValidationService: 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 queries
  • prefetch_related('line_items'): Efficiently loads line items

Standard CRUD Operations

List Transactions

Endpoint: GET /transactions/

Query Parameters:

  • status: Filter by status
  • store: Filter by store
  • search: Search by transactionId or customer name
  • ordering: Order by created_at or totalAmount
  • page: Page number
  • page_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:

  1. 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'])
  1. 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
  1. 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)
  1. 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):

ActionHTTP MethodURL Pattern
add_itemPOST/{transactionId}/add_item/
remove_itemDELETE/{transactionId}/remove_item/
update_quantityPATCH/{transactionId}/update_quantity/
apply_discountPOST/{transactionId}/apply_discount/
apply_promo_codePOST/{transactionId}/apply_promo_code/
remove_promo_codePOST/{transactionId}/remove_promo_code/
process_paymentPOST/{transactionId}/process_payment/
voidPOST/{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.

EndpointPurpose
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 filters
  • min_price, max_price: Price range filters
  • date_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:

  1. 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)
  1. Transaction Lookup:
original_transaction = SaleTransaction.objects.get(
    transactionId=request.data['original_transaction_id']
)
  1. Employee Resolution:
employee = request.user  # Use authenticated user directly (POS terminals)
  1. 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

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 UUID
  • return_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 TypeAccess Level
Authenticated CustomerAll fulfillments for their customer profile(s)
Guest User (with session)Only fulfillment linked to their checkout session
No AuthenticationEmpty 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.


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

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&register_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

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).


Was this page helpful?