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.pyapi/nextango/apps/transactions/services/return_service.pyapi/nextango/apps/transactions/services/validation_service.pyapi/nextango/apps/transactions/services/cash_drawer_service.pyapi/nextango/apps/transactions/services/inventory_adjustment_service.pyapi/nextango/apps/transactions/services/payment_service.pyapi/nextango/apps/transactions/services/receipt_service.py
Table of Contents
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 withemployee=None)customer(Customer): Customer making purchase (optional)source_system(str): Which channel created the transaction:pos,web_commerce,dashboard, orapi(see Models). Defaults toposwhen 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:
- 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()
- 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
)
- Apply Promotions and Recalculate:
if promo_code:
sale_transaction.appliedPromoCodes.add(promo_code)
sale_transaction.recalculate_totals(commit=True)
- 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'
)
- 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 stringproduct_variant_id: Product variant UUIDquantity: Quantity to addunit_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:
- Get Transaction:
txn = SaleTransaction.objects.get(transactionId=transaction_id)
- Validate and Get Promo Code:
promo = PromoCode.objects.get(code=promo_code.upper(), isActive=True)
- Add to Transaction:
txn.appliedPromoCodes.add(promo)
logger.info(f"Applied promo code {promo_code} to transaction {transaction_id}")
- 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 stringpayment_method_id,amount_paid: When both are provided, aTransactionPaymentrecord is created before the fully-paid check. When omitted (the/completeendpoint's default call), the transaction is finalized on the assumption that payment was already recorded via prior payment calls.
Implementation flow:
- Raises
TransactionServiceErrorif the transaction is not currentlyPENDING. - Pre-completion gates: calls
get_active_vertical_provider().run_pre_completion_gates(sale_transaction). ACheckoutGateBlockedraised by the active vertical (for example, a purchase-limit or age-verification failure) is caught and re-raised asTransactionServiceErrorso callers see a single exception type. - If
payment_method_idandamount_paidwere both provided, creates theTransactionPaymentrecord. - If the transaction is now fully paid, sets
status=COMPLETEDandcompletedAt, then callsget_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. - 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 viatransaction.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:
-
Get and lock transaction: fetches
SaleTransactionbytransactionId. -
Inventory reversal (status-dependent):
PENDINGtransactions: releases reserved inventoryCOMPLETEDtransactions: returns committed inventory viaInventoryAdjustmentService.void_committed_inventory()
-
Payment refunds: for
COMPLETEDorPARTIALLY_PAIDtransactions:
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
Refundrecord is created; these appear inGET /transactions/{transactionId}/response underpayment_records[].refunds. - The
finallyblock setspayment.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.
- 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
Does NOT reserve inventory - actual reservation happens during transaction completion.
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
PENDINGstatus; raisesTransactionServiceErrorotherwise. - Transaction must have an associated customer.
- Customer must hold at least
points_to_redeemonCustomer.loyalty_points. - Conversion rate: 1 point = $0.01 (100 points = $1.00).
- Deducts the points atomically via
F('loyalty_points') - points_to_redeemand records aLoyaltyLedgerentry withevent_type=REDEEM. - Does not modify
SaleTransactiontotals 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 returnedreturn_items_data: List of items being returned with quantitiesreason: Overall return reasonemployee: Employee processing the returnrefund_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):
- Validate Return Request:
self._validate_return_request(
original_transaction,
return_items_data,
refund_method,
refund_to_payment_ids
)
- Calculate Return Value (BEFORE creating return):
total_return_value = self._calculate_return_value(
return_items_data,
original_transaction
)
- 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)
- Create Return Record (AFTER refunds processed):
sales_return = Return.objects.create_return(
original_transaction=original_transaction,
employee=employee,
reason=reason
)
- Link Refunds to Return:
for refund in created_refunds:
refund.sales_return = sales_return
refund.save(update_fields=['sales_return'])
- Create Return Line Items:
return_items = self._create_return_items(
sales_return,
return_items_data
)
- Process Store Credit (if applicable):
if refund_method == 'store_credit':
self._process_store_credit_refund(
sales_return,
total_return_value
)
- Mark Return as Processed:
sales_return.mark_as_processed(processed_by_employee=employee)
- 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 transactiontotal_refund_amount: Total amount to refundpayment_ids: List of TransactionPayment IDs to refund toreason: 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:
- 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
})
- Sort by Remaining Balance (smallest first):
payments_with_balance.sort(key=lambda x: x['remaining_balance'])
- 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}"
)
- 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
ReturnValidationErrorif selected payments can't cover refund amount - Refund processing failure: Raises
ReturnValidationErrorif any individual refund fails - Invalid payment IDs: Raises
TransactionPayment.DoesNotExistif 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:
- 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}"
)
- Items Provided:
if not return_items_data or len(return_items_data) == 0:
raise ReturnValidationError("At least one item must be returned")
- Refund Method:
if refund_method not in ['original', 'store_credit']:
raise ReturnValidationError(
f"Invalid refund method: {refund_method}. Must be 'original' or 'store_credit'"
)
- 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)
- 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:
- 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}")
- 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")
- 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"
)
- 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:
- 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")
- 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
- 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
- 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
Snapshots (nameSnapshot, skuSnapshot) are auto-populated from original line item by model save() method.
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:
-
validate_transaction_creation(validated_data, request)- Validates store access
- Validates employee permissions
- Validates customer exists
- Validates line items data
-
validate_add_item_request(validated_data, transaction, request)- Validates transaction is pending
- Validates product variant exists
- Validates quantity is positive
-
validate_discount_request(data, transaction, employee, request)- Validates discount amount
- Checks manager override requirements
- Validates employee permissions
-
validate_promo_code_request(data, transaction, request)- Validates promo code format
- Checks promo code exists and is active
- Validates transaction eligibility
-
validate_payment_request(validated_data, transaction, request)- Validates payment amount
- Checks transaction balance
- Validates payment method exists
-
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:
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).
Currently minimal - most return validation logic is in ReturnService itself.
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 drawerclose_drawer(store_id, employee_id, register_id, shift_id, ending_amount, cash_breakdown): Close with variance calculationadd_cash(store_id, employee_id, amount, reason, notes): Pay-in operationremove_cash(store_id, employee_id, amount, reason, notes): Pay-out operationget_drawer_status(store_id, register_id): Current drawer staterecord_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 saleadjust_inventory_for_return(return_record): Restore inventory for returnadjust_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 constraintsget_payment_summary(transaction_id): Get payment status summary
Actual payment processing handled by TransactionService and payment gateway integrations.
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 instancecheckout_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 recordtransaction(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 UUIDline_item_ids(list): List of SaleLineItem UUIDscarrier_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 UUIDnew_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_atauto-set when status = 'shipped'delivered_atauto-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 throughOrderEmailService._send_via_hostinger()- Any other value (default:
resend) → routes throughOrderEmailService._send_via_resend()
send_receipt(transaction, to_email)
Sends a branded receipt email for a completed transaction.
| Parameter | Type | Description |
|---|---|---|
transaction | SaleTransaction | The completed transaction to receipt |
to_email | str | Recipient email address |
Returns:
{'success': True} # on success
{'success': False, 'error': '<message>'} # on failure
Process:
- Build HTML receipt via
_build_receipt_html(transaction) - Build plain-text receipt via
_build_receipt_text(transaction) - Set subject:
Receipt - {transaction.transactionId} - Dispatch via Hostinger or Resend based on
ORDER_EMAIL_PROVIDER - 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
| Method | Purpose |
|---|---|
_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) |
Related Documentation
- Models - Transaction data models
- Serializers - API serialization
- Views - API endpoints that delegate to these services
- Signals - Bidirectional sync with Sanity