Transactions Domain - Services Documentation

Complete documentation of Transactions domain business logic layer.

Last Updated: 2026-07-08

Sources:

  • api/nextango/apps/transactions/services/transaction_service.py
  • api/nextango/apps/transactions/services/return_service.py
  • api/nextango/apps/transactions/services/validation_service.py
  • api/nextango/apps/transactions/services/cash_drawer_service.py
  • api/nextango/apps/transactions/services/inventory_adjustment_service.py
  • api/nextango/apps/transactions/services/payment_service.py
  • api/nextango/apps/transactions/services/receipt_service.py

Table of Contents

  1. Overview
  2. TransactionService
  3. ReturnService
  4. Validation Services
  5. Supporting Services

Overview

The Transactions services layer encapsulates all business logic for transaction processing, returns, payments, and inventory adjustments.

Service Classes:

  • TransactionService: Transaction lifecycle management
  • ReturnService: Return and refund processing
  • TransactionValidationService: Transaction request validation
  • CashDrawerValidationService: Cash drawer operation validation
  • ReturnValidationService: Return request validation
  • CashDrawerService: Cash drawer operations
  • InventoryAdjustmentService: Inventory impact tracking
  • PaymentService: Payment method queries
  • ReceiptEmailService: Email receipts for POS and storefront transactions

Key Patterns:

  • @transaction.atomic: All state-changing operations wrapped in database transactions
  • Service Exceptions: Custom exception classes (TransactionServiceError, ReturnValidationError, etc.)
  • Separation of Concerns: Views delegate to services, services delegate to validators
  • Promotional Integration: PromotionService integration for discount calculations
  • Inventory Awareness: Real-time inventory checking before transactions
  • Audit Logging: Comprehensive logging of all financial operations

TransactionService

Purpose: Core business logic for transaction lifecycle management.

Source: services/transaction_service.py

Class Definition:

class TransactionService:
    """
    Service class encapsulating transaction business logic.
    All public methods use @transaction.atomic for data integrity.
    """

Exception Classes

Source: transaction_service.py:32-36

class TransactionServiceError(Exception):
    """Base exception for transaction service errors"""
    pass

class InsufficientInventoryError(TransactionServiceError):
    """Raised when requested quantity exceeds available inventory"""
    pass

Transaction Creation

create_transaction(store, employee=None, customer=None, source_system=None)

Source: transaction_service.py:40-47

Purpose: Create a new pending transaction.

Signature:

@transaction.atomic
def create_transaction(self, store, employee=None, customer=None, source_system=None) -> SaleTransaction:

Parameters:

  • store (Store): Store where transaction occurs (required)
  • employee (User): Employee processing transaction (optional; web commerce checkout calls this with employee=None)
  • customer (Customer): Customer making purchase (optional)
  • source_system (str): Which channel created the transaction: pos, web_commerce, dashboard, or api (see Models). Defaults to pos when omitted.

Returns: SaleTransaction with auto-generated transactionId and status='pending'

Implementation:

return SaleTransaction.objects.create_transaction(
    store=store,
    employee=employee,
    customer=customer,
    source_system=source_system,
)

Delegates To: SaleTransactionManager.create_transaction() for ID generation.

Web commerce checkout: CheckoutService calls this with employee=None and source_system='web_commerce', then sets fulfillmentMethod, sourceCheckoutId, and guest_access_token on the created transaction directly (see Web Commerce Services).


create_transaction_with_real_data()

Source: transaction_service.py:34-169

Purpose: Create a complete transaction using real Sanity-synced data for testing/demonstration.

Signature:

@staticmethod
def create_transaction_with_real_data():

Returns:

{
    'success': True,
    'transaction_id': 'TXN-20251117-A3B4C5D6',
    'total_amount': '59.99',
    'message': 'Transaction created and completed successfully'
}

Implementation Flow:

  1. Find Real Data:
store = Store.objects.first()
employee = Employee.objects.first()
product_variant = ProductVariant.objects.filter(
    inventory__gt=0,
    price__gt=0
).first()
promo_code = PromoCode.objects.filter(isActive=True).first()
  1. Create Transaction with Line Items:
sale_transaction = SaleTransaction.objects.create(
    transactionId=transaction_id,
    store=store,
    employee=employee,
    status='pending'
)

line_item = SaleLineItem.objects.create(
    transaction=sale_transaction,
    productVariant=product_variant,
    quantity=1,
    unitPrice=product_variant.price,
    totalPrice=product_variant.price
)
  1. Apply Promotions and Recalculate:
if promo_code:
    sale_transaction.appliedPromoCodes.add(promo_code)

sale_transaction.recalculate_totals(commit=True)
  1. Process Cash Payment:
payment_method = PaymentMethod.objects.filter(
    method_type='cash',
    is_active=True
).first()

payment = TransactionPayment.objects.create(
    transaction=sale_transaction,
    paymentMethod=payment_method,
    amount=sale_transaction.totalAmount,
    status='succeeded'
)
  1. Complete Transaction:
sale_transaction.status = 'completed'
sale_transaction.completedAt = timezone.now()
sale_transaction.save()

Use Case: Testing, demonstrations, load testing with realistic data.


Line Item Management

add_product_to_transaction(transaction_id, product_variant_id, quantity, unit_price=None)

Source: transaction_service.py:187-234

Purpose: Add a product to transaction, apply the active vertical's compliance snapshot, and recalculate totals.

Signature:

@transaction.atomic
def add_product_to_transaction(
    self,
    transaction_id: str,
    product_variant_id: uuid.UUID,
    quantity: int,
    unit_price: Decimal = None
) -> SaleTransaction:

Parameters:

  • transaction_id: Transaction ID string
  • product_variant_id: Product variant UUID
  • quantity: Quantity to add
  • unit_price: Override price (optional, uses variant.price if None)

Returns: Updated transaction with recalculated totals

Implementation: Validates inventory availability (InsufficientInventoryError if not enough is on hand), creates the SaleLineItem with the resolved unit price, then calls the agnostic compliance seam before recalculating:

line_item = SaleLineItem.objects.create(
    transaction=sale_transaction,
    productVariant=product_variant,
    quantity=quantity,
    unitPrice=unit_price,
    totalPrice=total_price,
    lineDiscount=line_discount,
)

# Populate the active vertical's compliance snapshot (if any) via the agnostic seam
on_line_item_added(line_item, store=sale_transaction.store)

sale_transaction.recalculate_totals()

on_line_item_added (nextango.apps.compliance.services.transaction_compliance) is the agnostic seam through which a vertical (for example, cannabis) attaches a compliance snapshot to the line item at add-time. It is a no-op under an agnostic-only deployment.

Raises: InsufficientInventoryError if inventory unavailable


add_product_to_transaction_without_recalc(transaction_id, product_variant_id, quantity, unit_price=None)

Source: transaction_service.py:236-277

Purpose: Add product WITHOUT recalculating totals (batch operation optimization).

Use Case: Adding multiple line items during transaction creation - recalculate once at the end instead of after each item.

Known gap: this path does not call the on_line_item_added compliance seam that add_product_to_transaction calls. TransactionViewSet's create-transaction flow (see Views) uses this batch path for every line item, so transactions created through POST /transactions/ do not get a vertical compliance snapshot attached to their line items at creation time, unlike items added one at a time via add_item.

Pattern (from views.py):

with disable_sync_signals():
    # Create transaction
    transaction = self.transaction_service.create_transaction(...)

    # Add multiple line items WITHOUT recalculating each time
    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)
        )

# Recalculate once at the end
transaction.recalculate_totals(commit=True)

Performance: Reduces N database writes to 1 when adding N line items.


update_line_item_quantity(transaction_id, line_item_id, new_quantity)

Source: transaction_service.py:389-434

Purpose: Update quantity of an existing line item.

Signature:

@transaction.atomic
def update_line_item_quantity(
    self,
    transaction_id: str,
    line_item_id: int,
    new_quantity: int
) -> SaleTransaction:

Implementation:

txn = SaleTransaction.objects.get(transactionId=transaction_id)
line_item = SaleLineItem.objects.get(id=line_item_id, transaction=txn)

# Check inventory for new quantity
if not self._check_inventory_availability(
    line_item.productVariant,
    new_quantity,
    txn.store
):
    raise InsufficientInventoryError("Insufficient inventory")

# Update quantity and total
line_item.quantity = new_quantity
line_item.totalPrice = line_item.unitPrice * new_quantity - line_item.lineDiscount
line_item.save()

# Recalculate transaction totals
txn.recalculate_totals(commit=True)

return txn

remove_product_from_transaction(transaction_id, line_item_id)

Source: transaction_service.py:464-470

Purpose: Remove a line item from transaction.

Implementation:

@transaction.atomic
def remove_product_from_transaction(
    self,
    transaction_id: str,
    line_item_id: str
) -> SaleTransaction:
    return self.remove_line_item(transaction_id, line_item_id)

Delegates To: remove_line_item() for actual deletion logic.


remove_line_item(transaction_id, line_item_id)

Source: transaction_service.py:436-462

Implementation:

txn = SaleTransaction.objects.get(transactionId=transaction_id)
line_item = SaleLineItem.objects.get(id=line_item_id, transaction=txn)

# Delete the line item
line_item.delete()

# Recalculate transaction totals
txn.recalculate_totals(commit=True)

return txn

Promotional Discounts

apply_promo_code(transaction_id, promo_code, store_id=None)

Source: transaction_service.py:262-304

Purpose: Apply promotional code to transaction using PromotionService.

Signature:

@transaction.atomic
def apply_promo_code(
    self,
    transaction_id: str,
    promo_code: str,
    store_id: str = None
) -> SaleTransaction:

Implementation Flow:

  1. Get Transaction:
txn = SaleTransaction.objects.get(transactionId=transaction_id)
  1. Validate and Get Promo Code:
promo = PromoCode.objects.get(code=promo_code.upper(), isActive=True)
  1. Add to Transaction:
txn.appliedPromoCodes.add(promo)
logger.info(f"Applied promo code {promo_code} to transaction {transaction_id}")
  1. Recalculate Totals (PromotionService integration happens here):
txn.recalculate_totals(commit=True)

PromotionService Integration: The recalculate_totals() method calls PromotionService.evaluate_transaction() to apply promotional discounts.

Returns: Updated transaction with promotional discounts applied.


remove_promo_code(transaction_id, promo_code)

Source: transaction_service.py:306-330

Purpose: Remove promotional code from transaction.

Implementation:

txn = SaleTransaction.objects.get(transactionId=transaction_id)
promo = PromoCode.objects.get(code=promo_code.upper())

# Remove promo code
txn.appliedPromoCodes.remove(promo)
logger.info(f"Removed promo code {promo_code} from transaction {transaction_id}")

# Recalculate totals (discounts removed)
txn.recalculate_totals(commit=True)

return txn

apply_line_item_discount(transaction_id, line_item_id, discount_amount, employee=None)

Source: transaction_service.py:332-387

Purpose: Apply manual discount to a specific line item.

Signature:

@transaction.atomic
def apply_line_item_discount(
    self,
    transaction_id: str,
    line_item_id: int,
    discount_amount: Decimal,
    employee=None
) -> SaleTransaction:

Implementation:

txn = SaleTransaction.objects.get(transactionId=transaction_id)
line_item = SaleLineItem.objects.get(id=line_item_id, transaction=txn)

# Validate discount amount
if discount_amount < 0:
    raise TransactionServiceError("Discount amount cannot be negative")

if discount_amount > line_item.totalPrice:
    raise TransactionServiceError("Discount cannot exceed line item total")

# Apply discount
line_item.lineDiscount = discount_amount
line_item.totalPrice = (line_item.unitPrice * line_item.quantity) - discount_amount
line_item.save()

logger.info(
    f"Applied \${discount_amount} discount to line item {line_item_id} "
    f"in transaction {transaction_id} by employee {employee.id if employee else 'system'}"
)

# Recalculate transaction totals
txn.recalculate_totals(commit=True)

return txn

Authorization: May require manager override for large discounts (validated by ValidationService).


Transaction Completion

complete_transaction(transaction_id, payment_method_id=None, amount_paid=None)

Source: transaction_service.py:511-616

Purpose: Complete a transaction, optionally recording a payment at the same time, running the active vertical's pre-completion compliance gates first.

Signature:

@transaction.atomic
def complete_transaction(
    self,
    transaction_id: str,
    payment_method_id: int = None,
    amount_paid: Decimal = None,
) -> SaleTransaction:

Parameters:

  • transaction_id: Transaction ID string
  • payment_method_id, amount_paid: When both are provided, a TransactionPayment record is created before the fully-paid check. When omitted (the /complete endpoint's default call), the transaction is finalized on the assumption that payment was already recorded via prior payment calls.

Implementation flow:

  1. Raises TransactionServiceError if the transaction is not currently PENDING.
  2. Pre-completion gates: calls get_active_vertical_provider().run_pre_completion_gates(sale_transaction). A CheckoutGateBlocked raised by the active vertical (for example, a purchase-limit or age-verification failure) is caught and re-raised as TransactionServiceError so callers see a single exception type.
  3. If payment_method_id and amount_paid were both provided, creates the TransactionPayment record.
  4. If the transaction is now fully paid, sets status=COMPLETED and completedAt, then calls get_active_vertical_provider().record_purchase_completion(sale_transaction) inside the same atomic block, so a failure there rolls back the status change and payment record together.
  5. Defers loyalty-points award (award_loyalty_points_for_transaction) and, when promo codes were applied, promo-usage increment and campaign-conversion tracking (increment_promo_usage_for_transaction, track_campaign_conversion_for_transaction) to Celery via transaction.on_commit, so the broker dispatch only fires after the database commit succeeds. Broker publish failures are caught and logged rather than propagated to the caller.

Returns: The updated SaleTransaction.


void_transaction(transaction_id, reason, employee=None)

Source: transaction_service.py:619-855

Purpose: Void a transaction. Returns committed inventory, issues gateway refunds for all succeeded payments, and marks the transaction voided.

Signature:

@transaction.atomic
def void_transaction(
    self,
    transaction_id: str,
    reason: str = "",
    employee=None
) -> SaleTransaction:

Implementation Flow:

  1. Get and lock transaction: fetches SaleTransaction by transactionId.

  2. Inventory reversal (status-dependent):

    • PENDING transactions: releases reserved inventory
    • COMPLETED transactions: returns committed inventory via InventoryAdjustmentService.void_committed_inventory()
  3. Payment refunds: for COMPLETED or PARTIALLY_PAID transactions:

successful_payments = sale_transaction.payment_records.filter(status='succeeded')
for payment in successful_payments:
    from nextango.apps.payments.services import RefundService
    success, refund, message = RefundService.process_refund(
        payment_id=payment.id,
        amount=payment.amount,
        reason=f'Transaction voided: {reason}',
    )
    # finally block always runs:
    payment.status = 'voided'
  • RefundService.process_refund() is called synchronously for each succeeded payment: gateway calls happen inline, which increases response time.
  • Each Refund record is created; these appear in GET /transactions/{transactionId}/ response under payment_records[].refunds.
  • The finally block sets payment.status = 'voided' regardless of whether the gateway refund succeeded. If a gateway call fails, the payment is still voided on our side: a manual reconciliation may be required.
  1. Mark transaction voided: sets status='voided', voidedAt, voidReason, then saves.

Returns: Voided SaleTransaction instance.

API note: POST /transactions/{transactionId}/void/ response time is longer than other write operations because gateway refund calls are synchronous. The response now includes associated Refund records. A 200 response means the transaction is voided; a refund failure does not cause a non-200: check payment_records[].refunds to verify refund status.


Inventory Management

_check_inventory_availability(product_variant, requested_quantity, store)

Source: transaction_service.py:856-881

Purpose: Check if requested quantity is available in inventory (private helper). Uses the store-specific InventoryLevel record, not the global ProductVariant record.

Signature:

def _check_inventory_availability(
    self,
    product_variant: ProductVariant,
    requested_quantity: int,
    store
) -> bool:

Implementation: Looks up the active InventoryLevel for this variant at this store by variant_sku + product + store + is_active=True, then compares available quantity against the request:

inventory_level = InventoryLevel.objects.get(
    variant_sku=product_variant.sku,
    product=product_variant.product,
    store=store,
    is_active=True
)

# Check available quantity (stock_on_hand - stock_reserved)
available_quantity = inventory_level.stock_on_hand - inventory_level.stock_reserved

return available_quantity >= requested_quantity

InventoryLevel.DoesNotExist (no record for this variant at this store) returns False: the product is treated as unavailable at that location rather than raising.

Returns: True if inventory available, False otherwise


Transaction Queries

get_transaction_summary(transaction_id)

Source: transaction_service.py:883-905

Purpose: Get comprehensive transaction summary with all related data.

Returns:

{
    'transaction_id': 'TXN-20251117-A3B4C5D6',
    'store': {
        'id': 'uuid',
        'name': 'Columbus Main Street',
        'store_code': 'COL001'
    },
    'employee': {
        'id': 'uuid',
        'name': 'John Doe'
    },
    'customer': {
        'id': 'uuid',
        'name': 'Alice Smith'
    },
    'line_items': [
        {
            'id': 'uuid',
            'sku': 'WIDG-001',
            'name': 'Widget Pro',
            'quantity': 2,
            'unit_price': '29.99',
            'line_discount': '0.00',
            'total_price': '59.98'
        }
    ],
    'totals': {
        'subtotal': '59.98',
        'tax_amount': '4.80',
        'order_level_discount': '0.00',
        'total_amount': '64.78'
    },
    'payments': [
        {
            'id': 'uuid',
            'amount': '64.78',
            'method': 'Credit Card',
            'status': 'succeeded'
        }
    ],
    'status': 'completed',
    'timestamps': {
        'created_at': '2025-11-17T10:00:00Z',
        'completed_at': '2025-11-17T10:05:00Z'
    }
}

Use Cases: Receipt generation, analytics, customer service lookups.


Loyalty Redemption

redeem_loyalty_points(transaction_id, points_to_redeem)

Source: transaction_service.py:905-967

Purpose: Apply loyalty points as a payment credit against an open (pending) transaction.

Rules:

  • Transaction must be in PENDING status; raises TransactionServiceError otherwise.
  • Transaction must have an associated customer.
  • Customer must hold at least points_to_redeem on Customer.loyalty_points.
  • Conversion rate: 1 point = $0.01 (100 points = $1.00).
  • Deducts the points atomically via F('loyalty_points') - points_to_redeem and records a LoyaltyLedger entry with event_type=REDEEM.
  • Does not modify SaleTransaction totals or create a payment record: the caller is responsible for recording the corresponding payment against the transaction using the returned dollar value.

Returns: {'redeemed_points': int, 'dollar_value': float, 'new_balance': int}


ReturnService

Purpose: Business logic for processing returns and refunds.

Source: services/return_service.py

Class Definition:

class ReturnService:
    """
    Service class for processing product returns and refunds.
    Handles return creation, validation, refund processing, and inventory adjustments.
    """

Refund-First Return Architecture (November 2025)

Critical Architectural Pattern: Returns follow a refund-first flow where refunds are processed BEFORE the return record is created.

Why This Matters:

  • Data Integrity: Refunds are financial transactions that should succeed or fail atomically
  • Payment Gateway Integration: Payment processors require immediate processing
  • Failure Recovery: If refund fails, we haven't created orphaned return records
  • Audit Trail: Refunds exist independently with proper timestamps

Architecture Flow:

┌─────────────────────────────────────────────────────────────┐
│ Refund-First Return Architecture                            │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  1. Validate Return Request                                 │
│     ↓                                                        │
│  2. Calculate Total Return Value                            │
│     ↓                                                        │
│  3. Process Refund(s) FIRST ← Creates Refund records       │
│     │  (sales_return=None at this point)                   │
│     ↓                                                        │
│  4. Create Return Record ← Creates Return record            │
│     ↓                                                        │
│  5. Link Refunds to Return ← Updates Refund.sales_return   │
│     ↓                                                        │
│  6. Create ReturnLineItems                                  │
│     ↓                                                        │
│  7. Mark Return as Processed                                │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Code Example:

# Step 2: Calculate BEFORE creating return
total_return_value = self._calculate_return_value(return_items_data, original_transaction)

# Step 3: Process refund FIRST (no return exists yet)
created_refunds = self._process_multi_payment_refund(
    original_transaction,
    total_return_value,
    refund_to_payment_ids,
    reason
)
# At this point: Refund records exist, but sales_return FK is NULL

# Step 4: Create return AFTER refunds processed
sales_return = Return.objects.create_return(
    original_transaction=original_transaction,
    employee=employee,
    reason=reason
)

# Step 5: Link refunds to return
for refund in created_refunds:
    refund.sales_return = sales_return
    refund.save(update_fields=['sales_return'])

Refund Model Support: The Refund model's sales_return FK is nullable to support this flow:

class Refund(models.Model):
    sales_return = models.ForeignKey(
        'transactions.Return',
        on_delete=models.SET_NULL,
        null=True,  # Allows refund-first flow
        blank=True,
        related_name='refunds'
    )

Exception Classes

Source: return_service.py:18-21

class ReturnValidationError(Exception):
    """Raised when return request validation fails"""
    pass

Main Return Processing

process_return(original_transaction, return_items_data, reason, employee, refund_method, refund_to_payment_ids=None)

Source: return_service.py:36-126

Purpose: Process a complete return with items and refund/store credit.

Signature:

@transaction.atomic
def process_return(
    self,
    original_transaction: SaleTransaction,
    return_items_data: List[Dict[str, Any]],
    reason: str,
    employee,
    refund_method: str,
    refund_to_payment_ids: List[str] = None
) -> Return:

Parameters:

  • original_transaction: Original sale being returned
  • return_items_data: List of items being returned with quantities
  • reason: Overall return reason
  • employee: Employee processing the return
  • refund_method: 'original' or 'store_credit'
  • refund_to_payment_ids: List of payment IDs to refund to (for 'original' method)

Return Items Data Format:

[
    {
        'transaction_item_id': 'line-item-uuid',
        'quantity': 2,
        'reason': 'changed_mind',  # damaged, wrong_size, unwanted_gift, defective, etc.
        'disposition': 'sellable'   # sellable, defective, quarantine, damaged_out, vendor_return
    }
]

Implementation Flow (Refund-First Architecture):

  1. Validate Return Request:
self._validate_return_request(
    original_transaction,
    return_items_data,
    refund_method,
    refund_to_payment_ids
)
  1. Calculate Return Value (BEFORE creating return):
total_return_value = self._calculate_return_value(
    return_items_data,
    original_transaction
)
  1. Process Refund(s) FIRST (before return exists):
created_refunds = []
if refund_method == 'original':
    # Distribute refund across multiple payments
    created_refunds = self._process_multi_payment_refund(
        original_transaction,
        total_return_value,
        refund_to_payment_ids,
        reason
    )
# Store credit processed later (after return exists)
  1. Create Return Record (AFTER refunds processed):
sales_return = Return.objects.create_return(
    original_transaction=original_transaction,
    employee=employee,
    reason=reason
)
  1. Link Refunds to Return:
for refund in created_refunds:
    refund.sales_return = sales_return
    refund.save(update_fields=['sales_return'])
  1. Create Return Line Items:
return_items = self._create_return_items(
    sales_return,
    return_items_data
)
  1. Process Store Credit (if applicable):
if refund_method == 'store_credit':
    self._process_store_credit_refund(
        sales_return,
        total_return_value
    )
  1. Mark Return as Processed:
sales_return.mark_as_processed(processed_by_employee=employee)
  1. Update Original Transaction Status:
self._update_transaction_status(original_transaction)
# Sets to 'refunded' or 'partially_refunded' based on return value

Returns: Created and processed Return record


Multi-Payment Refund Distribution

_process_multi_payment_refund(original_transaction, total_refund_amount, payment_ids, reason)

Source: return_service.py:273-374

Purpose: Distribute a refund amount across multiple payment methods intelligently. This is used when a customer paid with multiple payment methods (e.g., cash + card) and needs a refund.

Signature:

def _process_multi_payment_refund(
    self,
    original_transaction: SaleTransaction,
    total_refund_amount: Decimal,
    payment_ids: List[str],
    reason: str
) -> List[Refund]:

Parameters:

  • original_transaction: Original sale transaction
  • total_refund_amount: Total amount to refund
  • payment_ids: List of TransactionPayment IDs to refund to
  • reason: Refund reason (includes transaction ID and return reason)

Returns: List of created Refund objects

Distribution Strategy: Refund smallest payment amounts first to minimize fragmentation.

Why This Strategy:

  • Minimize fragmentation: Fully refund smaller payments rather than partial refunds across all
  • Simplify reconciliation: Fewer partially-refunded payments to track
  • Customer convenience: Completely resolves smaller payment methods first

Implementation Flow:

  1. Calculate Remaining Refundable Balance for each payment:
payments_with_balance = []
for payment_id in payment_ids:
    payment = TransactionPayment.objects.get(id=payment_id)

    # Calculate already refunded amount
    total_refunded = Refund.objects.filter(
        payment=payment,
        status='succeeded'
    ).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')

    # Calculate remaining refundable balance
    remaining_balance = payment.amount - total_refunded

    if remaining_balance > Decimal('0.00'):
        payments_with_balance.append({
            'payment': payment,
            'remaining_balance': remaining_balance
        })
  1. Sort by Remaining Balance (smallest first):
payments_with_balance.sort(key=lambda x: x['remaining_balance'])
  1. Validate Capacity before processing:
total_capacity = sum(p['remaining_balance'] for p in payments_with_balance)

if total_capacity < total_refund_amount:
    raise ReturnValidationError(
        f"Insufficient refundable balance. "
        f"Available: \${total_capacity:.2f}, "
        f"Requested: \${total_refund_amount:.2f}"
    )
  1. Distribute Refund across payments:
created_refunds = []
remaining_to_refund = total_refund_amount

for payment_data in payments_with_balance:
    if remaining_to_refund <= Decimal('0.00'):
        break  # Refund fully distributed

    payment = payment_data['payment']
    available = payment_data['remaining_balance']

    # Refund up to available balance
    refund_amount = min(remaining_to_refund, available)

    # Process refund through RefundService
    success, refund, message = RefundService.process_refund(
        payment_id=str(payment.id),
        amount=refund_amount,
        reason=f'Return for transaction {original_transaction.transactionId}: {reason}',
        sales_return=None  # Will be linked later in refund-first flow
    )

    if not success:
        raise ReturnValidationError(f"Refund failed: {message}")

    created_refunds.append(refund)
    remaining_to_refund -= refund_amount

return created_refunds

Example Scenarios:

Scenario 1: Two payments, full refund

# Original transaction: $150 total
# Payment 1 (Cash): $50
# Payment 2 (Card): $100
# Refund amount: $150

# Distribution:
# Step 1: Refund $50 to Cash (fully refunds payment 1)
# Step 2: Refund $100 to Card (fully refunds payment 2)
# Result: Both payments fully refunded

Scenario 2: Three payments, partial refund

# Original transaction: $200 total
# Payment 1 (Cash): $50
# Payment 2 (Card 1): $75
# Payment 3 (Card 2): $75
# Refund amount: $100

# Distribution (smallest first):
# Step 1: Refund $50 to Cash (fully refunds payment 1)
# Step 2: Refund $50 to Card 1 (partially refunds payment 2: $50/$75)
# Result: Cash fully refunded, Card 1 partially refunded, Card 2 untouched

Scenario 3: Previous partial refund, additional refund

# Original transaction: $150 total
# Payment 1 (Cash): $50 (already refunded $20)
# Payment 2 (Card): $100 (no refunds yet)
# New refund amount: $80

# Remaining balances:
# Payment 1: $30 remaining ($50 - $20)
# Payment 2: $100 remaining

# Distribution (smallest remaining first):
# Step 1: Refund $30 to Cash (fully refunds remaining balance)
# Step 2: Refund $50 to Card (partially refunds payment 2: $50/$100)
# Result: Cash fully refunded, Card partially refunded

Error Handling:

  • Insufficient capacity: Raises ReturnValidationError if selected payments can't cover refund amount
  • Refund processing failure: Raises ReturnValidationError if any individual refund fails
  • Invalid payment IDs: Raises TransactionPayment.DoesNotExist if payment ID not found

Validation

_validate_return_request(original_transaction, return_items_data, refund_method)

Source: return_service.py:128-193

Purpose: Validate return request before processing.

Validation Checks:

  1. Transaction Status:
if original_transaction.status not in [
    SaleTransaction.Status.COMPLETED,
    SaleTransaction.Status.PARTIALLY_REFUNDED
]:
    raise ReturnValidationError(
        f"Can only return completed transactions. Current status: {original_transaction.status}"
    )
  1. Items Provided:
if not return_items_data or len(return_items_data) == 0:
    raise ReturnValidationError("At least one item must be returned")
  1. Refund Method:
if refund_method not in ['original', 'store_credit']:
    raise ReturnValidationError(
        f"Invalid refund method: {refund_method}. Must be 'original' or 'store_credit'"
    )
  1. Item Data Validation:
transaction_items = {str(item.id): item for item in original_transaction.line_items.all()}

for item_data in return_items_data:
    self._validate_return_item_data(item_data, transaction_items)
  1. Refundable Amount:
total_return_value = self._calculate_return_value(return_items_data, original_transaction)

if total_return_value > original_transaction.get_refundable_amount():
    raise ReturnValidationError(
        f"Return value (\${total_return_value}) exceeds refundable amount "
        f"(\${original_transaction.get_refundable_amount()})"
    )

_validate_return_item_data(item_data, transaction_items)

Source: return_service.py:376-413

Purpose: Validate individual return item data.

Validation Checks:

  1. Required Fields:
required_fields = ['transaction_item_id', 'quantity', 'reason', 'disposition']
for field in required_fields:
    if field not in item_data:
        raise ReturnValidationError(f"Missing required field: {field}")
  1. Item Exists in Transaction:
item_id = str(item_data['transaction_item_id'])
if item_id not in transaction_items:
    raise ReturnValidationError(f"Item {item_id} not found in original transaction")
  1. Quantity Validation:
original_item = transaction_items[item_id]
return_quantity = int(item_data['quantity'])

if return_quantity <= 0:
    raise ReturnValidationError("Return quantity must be positive")

if return_quantity > original_item.quantity:
    raise ReturnValidationError(
        f"Cannot return {return_quantity} of item {item_id}. "
        f"Only {original_item.quantity} were purchased"
    )
  1. Enum Validation:
valid_reasons = ['damaged', 'wrong_size', 'unwanted_gift', 'defective', 'not_as_described', 'changed_mind']
if item_data['reason'] not in valid_reasons:
    raise ReturnValidationError(f"Invalid return reason: {item_data['reason']}")

valid_dispositions = ['sellable', 'defective', 'quarantine', 'damaged_out', 'vendor_return']
if item_data['disposition'] not in valid_dispositions:
    raise ReturnValidationError(f"Invalid disposition: {item_data['disposition']}")

Refund Processing

_process_multi_payment_refund(sales_return, total_value, original_transaction, refund_to_payment_ids)

Source: return_service.py:273-374

Purpose: Process refund to original payment method(s).

Implementation Flow:

  1. Get Succeeded Payments:
succeeded_payments = original_transaction.payment_records.filter(
    status='succeeded'
).order_by('created_at')

if not succeeded_payments.exists():
    raise ReturnValidationError("No succeeded payments found to refund")
  1. Handle Multiple Payments:
if refund_to_payment_ids:
    # Refund specific payments
    payments_to_refund = succeeded_payments.filter(
        id__in=refund_to_payment_ids
    )
else:
    # Refund all payments
    payments_to_refund = succeeded_payments
  1. Distribute Refund Across Payments:
remaining_refund = total_value

for payment in payments_to_refund:
    # Calculate refund amount for this payment
    refund_amount = min(remaining_refund, payment.amount)

    # Create refund record
    from nextango.apps.payments.models import Refund
    refund = Refund.objects.create(
        payment=payment,
        sales_return=sales_return,
        amount=refund_amount,
        status='succeeded',  # Simplified - real implementation processes with payment gateway
        processedAt=timezone.now()
    )

    remaining_refund -= refund_amount

    if remaining_refund <= 0:
        break
  1. Validate Full Refund:
if remaining_refund > Decimal('0.01'):  # Allow for rounding
    raise ReturnValidationError(
        f"Could not fully process refund. Remaining: \${remaining_refund}"
    )

_process_store_credit_refund(sales_return, total_value)

Source: return_service.py:465-510

Purpose: Issue store credit instead of refund to original payment.

Implementation:

customer = sales_return.originalSaleId.customer

if not customer:
    raise ReturnValidationError("Cannot issue store credit without customer")

# Create store credit
store_credit = StoreCredit.objects.create_store_credit(
    customer=customer,
    amount=total_value,
    expiration_days=365,  # 1 year expiration
    source_return=sales_return
)

# Create store credit transaction ledger entry
from .models import StoreCreditTransaction
StoreCreditTransaction.objects.create(
    store_credit=store_credit,
    transactionType='issue',
    amount=total_value,
    salesReturn=sales_return,
    notes=f"Store credit issued for return {sales_return.returnId}"
)

logger.info(
    f"Issued \${total_value} store credit ({store_credit.creditId}) "
    f"to customer {customer.id} for return {sales_return.returnId}"
)

Return Calculations

_calculate_return_value(return_items_data, original_transaction)

Source: return_service.py:195-238

Purpose: Calculate total value of items being returned.

Implementation:

total_value = Decimal('0.00')
transaction_items = {
    str(item.id): item
    for item in original_transaction.line_items.all()
}

for item_data in return_items_data:
    item_id = str(item_data['transaction_item_id'])
    original_item = transaction_items[item_id]
    return_quantity = int(item_data['quantity'])

    # Calculate proportional value
    # (unit price - proportional discount) * return quantity
    unit_value = original_item.unitPrice
    if original_item.lineDiscount > 0:
        # Proportional discount per unit
        discount_per_unit = original_item.lineDiscount / original_item.quantity
        unit_value -= discount_per_unit

    item_return_value = unit_value * return_quantity
    total_value += item_return_value

    logger.info(
        f"Return item {item_id}: {return_quantity} units @ \${unit_value} = \${item_return_value}"
    )

logger.info(f"Total return value calculated: \${total_value}")
return total_value

Calculation: Accounts for proportional discounts when calculating return value.


Return Line Item Creation

_create_return_items(sales_return, return_items_data)

Source: return_service.py:415-463

Purpose: Create ReturnLineItem records from return data.

Implementation:

return_items = []

for item_data in return_items_data:
    # Get original sale line item
    original_line_item = SaleLineItem.objects.get(
        id=item_data['transaction_item_id']
    )

    # Calculate return price (proportional to quantity)
    unit_value = original_line_item.unitPrice
    if original_line_item.lineDiscount > 0:
        discount_per_unit = original_line_item.lineDiscount / original_line_item.quantity
        unit_value -= discount_per_unit

    return_price = unit_value * item_data['quantity']

    # Create return line item
    return_item = ReturnLineItem.objects.create(
        sales_return=sales_return,
        original_sale_line_item=original_line_item,
        quantity=item_data['quantity'],
        returnPrice=return_price,
        reason=item_data['reason'],
        disposition=item_data['disposition']
    )

    return_items.append(return_item)

    logger.info(
        f"Created return line item: {return_item.skuSnapshot} "
        f"x{return_item.quantity} @ \${return_price}"
    )

return return_items

Transaction Status Updates

_update_transaction_status(transaction)

Source: return_service.py:607-635

Purpose: Update original transaction status based on return value.

Implementation:

refundable_amount = transaction.get_refundable_amount()

if refundable_amount <= Decimal('0.01'):  # Fully refunded (allow rounding)
    transaction.status = SaleTransaction.Status.REFUNDED
    logger.info(f"Transaction {transaction.transactionId} fully refunded")
else:
    transaction.status = SaleTransaction.Status.PARTIALLY_REFUNDED
    logger.info(
        f"Transaction {transaction.transactionId} partially refunded. "
        f"Remaining refundable: \${refundable_amount}"
    )

transaction.save(update_fields=['status'])

Return Queries

get_returnable_items(transaction)

Source: return_service.py:512-547

Purpose: Get list of items that can still be returned from a transaction.

Returns:

[
    {
        'line_item_id': 'uuid',
        'sku': 'WIDG-001',
        'name': 'Widget Pro',
        'quantity_purchased': 2,
        'quantity_already_returned': 1,
        'quantity_returnable': 1,
        'unit_price': '29.99',
        'max_refund_value': '29.99'
    }
]

Implementation: Calculates already returned quantities by aggregating ReturnLineItem records.


get_return_summary(return_id)

Source: return_service.py:549-605

Purpose: Get detailed return summary with all related data.

Returns:

{
    'return_id': 'RET-20251117-F7E8D9C0',
    'original_transaction_id': 'TXN-20251117-A3B4C5D6',
    'status': 'processed',
    'reason': 'Customer changed mind',
    'employee': {
        'id': 'uuid',
        'name': 'John Doe'
    },
    'items': [
        {
            'sku': 'WIDG-001',
            'name': 'Widget Pro',
            'quantity': 2,
            'return_price': '59.98',
            'reason': 'changed_mind',
            'disposition': 'sellable'
        }
    ],
    'refunds': [
        {
            'amount': '59.98',
            'payment_method': 'Credit Card',
            'status': 'succeeded',
            'processed_at': '2025-11-17T10:35:00Z'
        }
    ],
    'store_credits': [
        {
            'credit_id': 'SC-20251117-B1C2D3E4',
            'amount': '59.98',
            'expiration_date': '2026-11-17'
        }
    ],
    'total_value': '59.98',
    'timestamps': {
        'created_at': '2025-11-17T10:30:00Z',
        'processed_at': '2025-11-17T10:35:00Z'
    }
}

Validation Services

Purpose: Request validation logic separated from business logic.

Source: services/validation_service.py


TransactionValidationService

Source: validation_service.py:22-356

Purpose: Validate transaction-related requests.

Methods:

  1. validate_transaction_creation(validated_data, request)

    • Validates store access
    • Validates employee permissions
    • Validates customer exists
    • Validates line items data
  2. validate_add_item_request(validated_data, transaction, request)

    • Validates transaction is pending
    • Validates product variant exists
    • Validates quantity is positive
  3. validate_discount_request(data, transaction, employee, request)

    • Validates discount amount
    • Checks manager override requirements
    • Validates employee permissions
  4. validate_promo_code_request(data, transaction, request)

    • Validates promo code format
    • Checks promo code exists and is active
    • Validates transaction eligibility
  5. validate_payment_request(validated_data, transaction, request)

    • Validates payment amount
    • Checks transaction balance
    • Validates payment method exists
  6. validate_void_request(validated_data, transaction, employee, request)

    • Validates void reason provided
    • Checks manager override requirements
    • Validates transaction can be voided

CashDrawerValidationService

Source: validation_service.py:358-472

Purpose: Validate cash drawer operation requests.

Methods:

  1. validate_drawer_operation(operation_type, validated_data, store_id, employee_id, request)
    • Validates operation type (open, close, cash_in, cash_out, till_count)
    • Validates store access
    • Validates employee permissions
    • Validates amounts for operations
    • Checks drawer state consistency

ReturnValidationService

Source: validation_service.py:474-end

Purpose: Validate return processing requests (future expansion).


Supporting Services

CashDrawerService

Purpose: Cash drawer lifecycle management.

Source: services/cash_drawer_service.py

Key Methods:

  • open_drawer(store_id, employee_id, register_id, shift_id, starting_amount): Open drawer
  • close_drawer(store_id, employee_id, register_id, shift_id, ending_amount, cash_breakdown): Close with variance calculation
  • add_cash(store_id, employee_id, amount, reason, notes): Pay-in operation
  • remove_cash(store_id, employee_id, amount, reason, notes): Pay-out operation
  • get_drawer_status(store_id, register_id): Current drawer state
  • record_till_count(store_id, employee_id, counted_amount, cash_breakdown, notes): Mid-shift count

Variance Calculation:

expected_amount = opening_amount + sales - refunds + pay_ins - pay_outs
variance = counted_amount - expected_amount

InventoryAdjustmentService

Purpose: Track inventory impacts from transactions and returns.

Source: services/inventory_adjustment_service.py

Key Methods:

  • adjust_inventory_for_sale(transaction): Deduct inventory for sale
  • adjust_inventory_for_return(return_record): Restore inventory for return
  • adjust_inventory_for_void(transaction): Restore inventory for void

Implementation: Creates InventoryAdjustment records and updates InventoryLevel quantities.


PaymentService

Purpose: Payment method queries.

Source: services/payment_service.py

Key Methods:

  • get_available_payment_methods(transaction_type, amount=None): Filter payment methods by context and amount constraints
  • get_payment_summary(transaction_id): Get payment status summary

FulfillmentService

Purpose: Handles creation and management of fulfillment records. Orchestrates the creation of Fulfillment and Shipment records from checkout data.

Source: services/fulfillment_service.py

Added: November 2025

Class Definition:

class FulfillmentService:
    """
    Service for managing fulfillment records.
    Coordinates Fulfillment and Shipment creation during checkout.
    """

create_from_checkout(transaction, checkout_data)

Purpose: Create Fulfillment record from checkout completion data.

Signature:

@staticmethod
def create_from_checkout(transaction, checkout_data):

Parameters:

  • transaction (SaleTransaction): Transaction instance
  • checkout_data (dict): Checkout form data from frontend

Required Keys in checkout_data:

{
    'email': '[email protected]',
    'shipping_address': {
        'firstName': 'John',
        'lastName': 'Doe',
        'address': '123 Main St',
        'city': 'Columbus',
        'region': 'OH',
        'postalCode': '43215',
        'country': 'US',
        'phone': '555-1234'
    },
    'delivery_method': {
        'id': 'standard-shipping',
        'title': 'Standard Shipping',
        'turnaround': '5-7 business days',
        'price': '$7.99',
        'priceValue': 7.99
    }
}

Optional Keys:

{
    'billing_address': {...},           # If billing_same_as_shipping is False
    'billing_same_as_shipping': True,   # Default True
    'payment_details': {
        'nameOnCard': 'John Doe',
        'cardNumber': '4111111111111111',  # Only last 4 stored
        'cardType': 'Visa'
    },
    'shipping_cost': Decimal('7.99')
}

Returns: Fulfillment instance

Security: Card numbers are masked - only last 4 digits stored.


create_initial_shipment(fulfillment, transaction)

Purpose: Create initial shipment for a fulfillment. By default, all line items go into a single shipment.

Signature:

@staticmethod
def create_initial_shipment(fulfillment, transaction):

Parameters:

  • fulfillment (Fulfillment): Parent fulfillment record
  • transaction (SaleTransaction): Transaction with line items

Returns: Shipment instance with all line items linked


create_split_shipment(fulfillment_id, line_item_ids, carrier_info=None)

Purpose: Create a new shipment for specific line items. Used when order is fulfilled from multiple warehouses/times.

Signature:

@staticmethod
def create_split_shipment(fulfillment_id, line_item_ids, carrier_info=None):

Parameters:

  • fulfillment_id (UUID): Fulfillment record UUID
  • line_item_ids (list): List of SaleLineItem UUIDs
  • carrier_info (dict, optional): {'carrier': 'UPS', 'tracking': '1Z...'}

Returns: Shipment instance

Use Case: Split shipments when items ship from different locations:

# Order has items from two warehouses
shipment1 = FulfillmentService.create_split_shipment(
    fulfillment_id=fulfillment.id,
    line_item_ids=[item1.id, item2.id],
    carrier_info={'carrier': 'UPS', 'tracking': '1Z123456'}
)

shipment2 = FulfillmentService.create_split_shipment(
    fulfillment_id=fulfillment.id,
    line_item_ids=[item3.id],
    carrier_info={'carrier': 'FedEx', 'tracking': '794644'}
)

update_shipment_status(shipment_id, new_status, **kwargs)

Purpose: Update shipment status and associated timestamps.

Signature:

@staticmethod
def update_shipment_status(shipment_id, new_status, **kwargs):

Parameters:

  • shipment_id (UUID): Shipment record UUID
  • new_status (str): New status value ('pending', 'picked', 'packed', 'shipped', 'in_transit', 'delivered', 'failed')
  • **kwargs: Optional fields (shipped_at, delivered_at, carrier_name, tracking_number)

Returns: Shipment instance

Auto-Timestamps:

  • shipped_at auto-set when status = 'shipped'
  • delivered_at auto-set when status = 'delivered'

Usage:

# Mark as shipped with tracking
shipment = FulfillmentService.update_shipment_status(
    shipment_id=shipment.id,
    new_status='shipped',
    carrier_name='UPS',
    tracking_number='1Z999AA10123456784'
)

# Mark as delivered
shipment = FulfillmentService.update_shipment_status(
    shipment_id=shipment.id,
    new_status='delivered'
)


ReceiptEmailService

Source: api/nextango/apps/transactions/services/receipt_service.py

Purpose: Sends email receipts for completed POS and storefront transactions. Reuses the email infrastructure from OrderEmailService (Resend API or Hostinger SMTP, selected by the ORDER_EMAIL_PROVIDER environment variable).

When called: After payment completion on POS transactions or web checkout, when the operator or storefront triggers receipt delivery. The service is invoked from the send-receipt endpoint (POST /transactions/{id}/send-receipt/).

Email provider selection: Checks ORDER_EMAIL_PROVIDER environment variable:

  • hostinger → routes through OrderEmailService._send_via_hostinger()
  • Any other value (default: resend) → routes through OrderEmailService._send_via_resend()

send_receipt(transaction, to_email)

Sends a branded receipt email for a completed transaction.

ParameterTypeDescription
transactionSaleTransactionThe completed transaction to receipt
to_emailstrRecipient email address

Returns:

{'success': True}                           # on success
{'success': False, 'error': '<message>'}    # on failure

Process:

  1. Build HTML receipt via _build_receipt_html(transaction)
  2. Build plain-text receipt via _build_receipt_text(transaction)
  3. Set subject: Receipt - {transaction.transactionId}
  4. Dispatch via Hostinger or Resend based on ORDER_EMAIL_PROVIDER
  5. Log outcome; return success/error dict

Email content includes:

  • Store name, address, and phone number (from store.address_data)
  • Transaction ID and date
  • Line items table: product name, variant name (if different), SKU snapshot, quantity, line total
  • Order-level discount row (if orderLevelDiscount > 0)
  • Subtotal, tax, and total
  • Payment details section: payment method, amount, tender amount, and change due (for cash payments)

Error handling: All exceptions are caught; failures return {'success': False, 'error': ...} and log at ERROR level. The caller (view) decides whether to surface the error to the client.


Private helpers

MethodPurpose
_build_receipt_html(transaction)Builds branded HTML email body
_build_receipt_text(transaction)Builds plain-text email body
_build_payment_html(transaction)Builds payment details HTML section (called from _build_receipt_html)

  • Models - Transaction data models
  • Serializers - API serialization
  • Views - API endpoints that delegate to these services
  • Signals - Bidirectional sync with Sanity

Was this page helpful?