Transactions Domain - Signals Documentation
Complete documentation of Transactions domain Django signal handlers for bidirectional Sanity sync.
Source: api/nextango/apps/transactions/signals.py
Last Updated: 2026-07-08
Table of Contents
- Overview
- Transaction Signals
- Payment Signals
- Line Item Signals
- Return & Credit Signals
- Deletion Signals
- Helper Functions
Overview
The Transactions signals module handles Django→Sanity synchronization for all transaction-related models using asynchronous background tasks.
Key Architecture:
- Asynchronous Sync: All sync operations queued via Celery tasks
- Race Condition Prevention: Pending transactions not synced until completed
- Parent-Child Coordination: Line items/payments only sync after parent transaction
- Real-Time Updates: WebSocket broadcasts to connected POS terminals
- Inventory Integration: Automatic inventory movement processing
- Customer Data Updates: Order history and loyalty points
Signal Handlers documented in detail below:
sync_sale_transaction_to_sanity- Sync completed transactionsupdate_customer_on_transaction_complete- Update customer datasync_payment_to_sanity- Sync paymentssync_transaction_item_to_sanity- Sync line items with race condition preventionprocess_inventory_movement_from_sale_item- Process inventoryrelease_inventory_on_line_item_delete- Release inventory on deletionsync_return_to_sanity- Sync returnssync_store_credit_to_sanity- Sync store creditssync_payment_method_to_sanity- Sync payment methodsdelete_sale_transaction_from_sanity- Handle transaction deletiondelete_payment_from_sanity- Handle payment deletion
Additional signal handlers on SaleTransaction, Shipment, and ShipmentLineItem exist in the current module beyond the eleven above (Sanity sync for ReturnLineItem and PickupSchedule, PaymentProcessor sync, shipment/fulfillment status propagation, analytics-cache invalidation on completion, inventory-analytics and A/B-test-conversion counters on completion, and daily-revenue counter increments). They are not individually detailed on this page; read signals.py directly for their dispatch_uid values and trigger conditions.
Loop Prevention: All signals use should_skip_sync_to_sanity() context check.
Transaction Signals
sync_sale_transaction_to_sanity
Purpose: Sync SaleTransaction to Sanity after save (only completed transactions).
Source: signals.py:22-52
Trigger: post_save on transactions.SaleTransaction
Signature:
@receiver(post_save, sender='transactions.SaleTransaction', dispatch_uid='sync_sale_transaction_to_sanity')
def sync_sale_transaction_to_sanity(sender, instance, created, **kwargs):
Implementation
1. Loop Prevention Check:
if should_skip_sync_to_sanity():
logger.debug(f"Skipping SaleTransaction {instance.pk} sync - sync source check")
return
2. Status Check - CRITICAL RACE CONDITION PREVENTION:
# Skip sync for pending transactions (awaiting payment completion)
if instance.status == 'pending':
logger.debug(f"Skipping SaleTransaction {instance.pk} sync - status is pending")
return
Why: Pending transactions remain local until payment is applied. This prevents:
- Race conditions where line items reference non-existent transaction documents
- Partial transaction data syncing to Sanity
- Payment processing conflicts
3. Async Task Dispatch:
transaction.on_commit(
lambda: _dispatch_transaction_sync(instance, created, 'SaleTransaction')
)
4. WebSocket Broadcast:
if instance.store and hasattr(instance.store, 'sanity_id'):
transaction.on_commit(
lambda: _broadcast_transaction_update(instance, created)
)
Behavior:
- Transactions with status='pending' are NOT synced
- Only 'completed', 'voided', 'refunded', 'partially_refunded' are synced
- Uses
transaction.on_commit()to ensure database consistency
update_customer_on_transaction_complete
Purpose: Update customer order history and loyalty points when transaction completes.
Source: signals.py:55-77
Trigger: post_save on transactions.SaleTransaction
Signature:
@receiver(post_save, sender='transactions.SaleTransaction', dispatch_uid='update_customer_on_transaction_complete')
def update_customer_on_transaction_complete(sender, instance, created, **kwargs):
Implementation
1. Conditional Processing:
# Only process completed transactions with a customer
if instance.status != 'completed' or not instance.customer:
return
2. Async Customer Update:
transaction.on_commit(
lambda: _dispatch_customer_update(instance)
)
Customer Update Logic (see _dispatch_customer_update at line 449):
- Add to Order History:
transaction_ref = str(transaction_instance.sanity_id) if transaction_instance.sanity_id else None
if transaction_ref and transaction_ref not in customer.order_history:
customer.order_history.append(transaction_ref)
- Award Loyalty Points:
loyalty_points_earned = int(transaction_instance.totalAmount)
if loyalty_points_earned > 0:
customer.loyalty_points += loyalty_points_earned
Rule: 1 loyalty point per dollar spent
- Update User Role Data:
user = customer.user
if user and user.user_roles_data:
for i, role in enumerate(user.user_roles_data):
if isinstance(role, dict) and role.get('_type') == 'roleCustomer':
# Update orderHistory array
role['orderHistory'].append({
'_type': 'reference',
'_ref': transaction_ref
})
# Update loyaltyPoints
role['loyaltyPoints'] = customer.loyalty_points
Behavior: Race-condition free by using transaction.on_commit().
Payment Signals
sync_payment_to_sanity
Purpose: Sync TransactionPayment to Sanity after save (only for completed parent transactions).
Source: signals.py:80-110
Trigger: post_save on payments.TransactionPayment
Signature:
@receiver(post_save, sender='payments.TransactionPayment', dispatch_uid='sync_payment_to_sanity')
def sync_payment_to_sanity(sender, instance, created, **kwargs):
Implementation - CRITICAL RACE CONDITION PREVENTION
1. Skip Newly Created Payments:
# Skip sync for newly created payments - they'll be synced after parent transaction
if created:
logger.debug(f"Skipping newly created Payment {instance.pk} sync - will be synced after parent transaction")
return
Why: Prevents race condition where payment references non-existent transaction document in Sanity.
2. Check Parent Transaction Status:
# Skip sync if parent transaction is pending
if hasattr(instance, 'transaction') and instance.transaction and instance.transaction.status == 'pending':
logger.debug(f"Skipping Payment {instance.pk} sync - parent transaction {instance.transaction.pk} is pending")
return
Why: Payments only sync AFTER parent transaction is completed and synced.
3. Async Sync Dispatch:
transaction.on_commit(
lambda: _dispatch_payment_sync(instance, created)
)
Sync Flow:
- Payment created → Signal skips (parent pending)
- Transaction completed → Transaction synced to Sanity
- Payment updated/accessed → Payment synced (parent now exists in Sanity)
Line Item Signals
sync_transaction_item_to_sanity
Purpose: Sync SaleLineItem to Sanity with data completeness and race condition checks.
Source: signals.py:113-141
Trigger: post_save on transactions.SaleLineItem
Signature:
@receiver(post_save, sender='transactions.SaleLineItem', dispatch_uid='sync_transaction_item_to_sanity')
def sync_transaction_item_to_sanity(sender, instance, created, **kwargs):
Implementation - DATA COMPLETENESS + RACE CONDITION FIXES
1. Data Completeness Check:
# Use model validation method
if not instance.is_sync_ready:
logger.debug(f"SaleLineItem {instance.pk} missing required data, deferring sync")
return
Validation (from model):
@property
def is_sync_ready(self):
return all([
self.transaction_id,
self.productVariant_id,
self.totalPrice is not None,
self.unitPrice is not None,
self.quantity is not None and self.quantity > 0,
self.skuSnapshot,
self.nameSnapshot
])
2. Parent Sync Check:
# Check if parent transaction already has sanity_id
if instance.can_sync_to_sanity():
# Both data complete AND parent synced - safe to sync line item immediately
transaction.on_commit(
lambda: _dispatch_transaction_item_sync(instance, created)
)
else:
# Parent not synced yet, will be handled by parent completion
logger.debug(f"SaleLineItem {instance.pk} sync deferred until parent transaction completes")
Validation (from model):
def can_sync_to_sanity(self):
return (
self.is_sync_ready and
self.transaction.sanity_id is not None
)
Sync Flow:
- Line item created during transaction building → Deferred (parent has no sanity_id yet)
- Transaction completed → Transaction synced to Sanity (gets sanity_id)
- Line items checked → Now can sync (parent exists, data complete)
process_inventory_movement_from_sale_item
Purpose: Process inventory movement when SaleLineItem is created.
Source: signals.py:144-163
Trigger: post_save on transactions.SaleLineItem (only on creation)
Signature:
@receiver(post_save, sender='transactions.SaleLineItem', dispatch_uid='process_inventory_movement_from_sale_item')
def process_inventory_movement_from_sale_item(sender, instance, created, **kwargs):
Implementation
1. Only Process New Items:
# Only process for newly created SaleLineItems (not updates)
if not created:
return
2. Async Inventory Movement:
transaction.on_commit(
lambda: _dispatch_inventory_movement_processing(instance, created)
)
Inventory Movement Processing:
- Creates
InventoryMovementrecord - Updates
InventoryLevelquantities - Tracks product movement for auditing
- Handled by
InventoryAdjustmentService
release_inventory_on_line_item_delete
Purpose: Release inventory when SaleLineItem is deleted from pending transaction.
Source: signals.py:166-190
Trigger: post_delete on transactions.SaleLineItem
Signature:
@receiver(post_delete, sender='transactions.SaleLineItem', dispatch_uid='release_inventory_on_line_item_delete')
def release_inventory_on_line_item_delete(sender, instance, **kwargs):
Implementation - SYNCHRONOUS (CRITICAL)
Why Synchronous: Instance is being deleted and cannot be fetched later by async task.
# Release inventory synchronously since instance is being deleted
from .services.inventory_adjustment_service import InventoryAdjustmentService
release_result = InventoryAdjustmentService.release_inventory(instance)
if release_result['status'] == 'success':
logger.info(f"Successfully released inventory for deleted SaleLineItem {instance.pk}")
else:
logger.error(f"Inventory release failed for SaleLineItem {instance.pk}: {release_result}")
Behavior:
- Returns items to available stock immediately
- Critical for maintaining inventory accuracy
- Must run synchronously (no async task)
Return & Credit Signals
sync_return_to_sanity
Purpose: Sync Return to Sanity after save.
Source: signals.py:193-214
Trigger: post_save on transactions.Return
Signature:
@receiver(post_save, sender='transactions.Return', dispatch_uid='sync_return_to_sanity')
def sync_return_to_sanity(sender, instance, created, **kwargs):
Implementation
1. Async Sync Dispatch:
transaction.on_commit(
lambda: _dispatch_return_sync(instance, created)
)
2. WebSocket Broadcast:
if instance.originalSaleId and instance.originalSaleId.store and hasattr(instance.originalSaleId.store, 'sanity_id'):
transaction.on_commit(
lambda: _broadcast_return_processed(instance)
)
Behavior:
- Syncs return record with line items
- Broadcasts to POS terminals
- Updates original transaction status
sync_store_credit_to_sanity
Purpose: Sync StoreCredit to Sanity after save.
Source: signals.py:217-232
Trigger: post_save on transactions.StoreCredit
Signature:
@receiver(post_save, sender='transactions.StoreCredit', dispatch_uid='sync_store_credit_to_sanity')
def sync_store_credit_to_sanity(sender, instance, created, **kwargs):
Implementation
transaction.on_commit(
lambda: _dispatch_store_credit_sync(instance, created)
)
Behavior: Syncs store credit balances to customer accounts in Sanity.
sync_payment_method_to_sanity
Purpose: Sync PaymentMethod configuration to Sanity.
Source: signals.py:235-250
Trigger: post_save on payments.PaymentMethod
Signature:
@receiver(post_save, sender='payments.PaymentMethod', dispatch_uid='sync_payment_method_to_sanity')
def sync_payment_method_to_sanity(sender, instance, created, **kwargs):
Implementation
transaction.on_commit(
lambda: _dispatch_payment_method_sync(instance, created)
)
Behavior: Syncs payment method configuration for POS terminals.
Deletion Signals
delete_sale_transaction_from_sanity
Purpose: Handle SaleTransaction deletion with soft delete approach.
Source: signals.py:253-268
Trigger: post_delete on transactions.SaleTransaction
Signature:
@receiver(post_delete, sender='transactions.SaleTransaction', dispatch_uid='delete_sale_transaction_from_sanity')
def delete_sale_transaction_from_sanity(sender, instance, **kwargs):
Implementation - SOFT DELETE
transaction.on_commit(
lambda: _dispatch_transaction_deletion(instance, 'SaleTransaction')
)
Soft Delete Logic (see _dispatch_transaction_deletion at line 415):
if hasattr(instance, 'sanity_id') and instance.sanity_id:
# Mark as deleted in Sanity while preserving audit trail
logger.info(f"Marked {transaction_type} {instance.pk} as deleted in Sanity")
else:
logger.debug(f"No Sanity ID found for deleted {transaction_type} {instance.pk}")
Behavior: Maintains audit trail instead of hard deleting from Sanity.
delete_payment_from_sanity
Purpose: Handle TransactionPayment deletion and update parent transaction.
Source: signals.py:271-286
Trigger: post_delete on payments.TransactionPayment
Signature:
@receiver(post_delete, sender='payments.TransactionPayment', dispatch_uid='delete_payment_from_sanity')
def delete_payment_from_sanity(sender, instance, **kwargs):
Implementation
transaction.on_commit(
lambda: _dispatch_payment_deletion(instance)
)
Payment Deletion Logic (see _dispatch_payment_deletion at line 428):
- Mark Payment as Deleted:
if hasattr(instance, 'sanity_id') and instance.sanity_id:
logger.info(f"Marked Payment {instance.pk} as deleted in Sanity")
- Update Parent Transaction:
# Update parent transaction payment summary using async task
if hasattr(instance, 'transaction') and instance.transaction:
task_result = async_transaction_sync_task.delay(
model_name='SaleTransaction',
instance_id=instance.transaction.pk,
created=False,
sync_type='transaction_sync'
)
Behavior: Ensures parent transaction reflects payment deletion.
Helper Functions
Async Task Dispatch Functions
All sync operations are dispatched via Celery tasks for asynchronous processing.
_dispatch_transaction_sync(instance, created, transaction_type)
Source: signals.py:291-303
Purpose: Queue async task for transaction sync.
Implementation:
task_result = async_transaction_sync_task.delay(
model_name=transaction_type,
instance_id=instance.pk,
created=created,
sync_type='transaction_sync'
)
logger.info(f"Queued async sync for {transaction_type} {instance.pk} (task: {task_result.id})")
Task: async_transaction_sync_task (Celery task in integrations app)
_dispatch_payment_sync(instance, created)
Source: signals.py:306-319
Purpose: Queue async task for payment sync.
Implementation:
task_result = async_transaction_sync_task.delay(
model_name='Payment',
instance_id=instance.pk,
created=created,
sync_type='payment_sync',
parent_transaction_id=instance.transaction.pk if hasattr(instance, 'transaction') and instance.transaction else None
)
Includes parent_transaction_id for reference resolution.
_dispatch_transaction_item_sync(instance, created)
Source: signals.py:322-336
Purpose: Queue async task for line item sync.
Implementation:
task_result = async_transaction_sync_task.delay(
model_name='SaleLineItem',
instance_id=instance.pk,
created=created,
sync_type='transaction_item_sync',
parent_transaction_id=instance.transaction.pk if hasattr(instance, 'transaction') and instance.transaction else None
)
_dispatch_inventory_movement_processing(instance, created)
Source: signals.py:339-353
Purpose: Queue async task for inventory movement processing.
Implementation:
task_result = async_transaction_sync_task.delay(
model_name='SaleLineItem',
instance_id=instance.pk,
created=created,
sync_type='inventory_movement',
parent_transaction_id=instance.transaction.pk if hasattr(instance, 'transaction') and instance.transaction else None
)
Processing: Creates InventoryMovement records and updates InventoryLevel quantities.
WebSocket Broadcasting Functions
Real-time updates to connected POS terminals.
_broadcast_transaction_update(transaction_instance, created)
Source: signals.py:518-539
Purpose: Broadcast transaction updates via WebSocket.
Implementation:
store_id = transaction_instance.store.sanity_id
if created:
WebSocketBroadcaster.broadcast_transaction_created(transaction_instance, store_id)
elif transaction_instance.status == 'completed':
# Get payment method from latest payment
payment_method = None
latest_payment = transaction_instance.payment_records.filter(status='succeeded').first()
if latest_payment and latest_payment.payment_method:
payment_method = latest_payment.payment_method.name
WebSocketBroadcaster.broadcast_transaction_completed(transaction_instance, store_id, payment_method)
else:
WebSocketBroadcaster.broadcast_transaction_updated(transaction_instance, store_id)
Broadcast Types:
broadcast_transaction_created: New transactionbroadcast_transaction_completed: Transaction completed with payment methodbroadcast_transaction_updated: Status/data change
Target: All POS terminals connected to store_{store_id} channel.
_broadcast_return_processed(return_instance)
Source: signals.py:542-550
Purpose: Broadcast return processing via WebSocket.
Implementation:
store_id = return_instance.originalSaleId.store.sanity_id
WebSocketBroadcaster.broadcast_return_processed(return_instance, store_id)
Use Case: Notify POS terminals when returns are processed for real-time inventory updates.
Key Patterns
1. Async Task Queuing
Pattern: All sync operations use transaction.on_commit() + Celery tasks.
Benefits:
- Non-blocking sync (immediate user feedback)
- Database consistency guaranteed
- Retry capabilities
- Scalability
Example:
transaction.on_commit(
lambda: async_transaction_sync_task.delay(
model_name='SaleTransaction',
instance_id=instance.pk,
created=created,
sync_type='transaction_sync'
)
)
2. Race Condition Prevention
Pattern: Parent-child sync coordination.
Rules:
- Pending transactions: NOT synced
- Line items: Only sync after parent has sanity_id
- Payments: Only sync after parent completed
Critical Code:
# Transaction
if instance.status == 'pending':
return # Don't sync
# Line Item
if not instance.can_sync_to_sanity():
return # Wait for parent
# Payment
if created or instance.transaction.status == 'pending':
return # Don't sync
3. Loop Prevention
Pattern: All signals check sync source.
if should_skip_sync_to_sanity():
logger.debug(f"Skipping sync - sync source check")
return
Context Check (from sync_context.py):
def should_skip_sync_to_sanity():
# Check thread-local context
# Skip if sync_source == 'sanity_webhook'
# Skip if in load_testing_mode
return SyncContext.get_sync_source() == 'sanity_webhook'
4. Data Completeness Validation
Pattern: Validate data before sync.
if not instance.is_sync_ready:
logger.debug(f"Missing required data, deferring sync")
return
Prevents: Syncing incomplete records that would fail validation.
5. Real-Time Communication
Pattern: WebSocket broadcasts for instant POS updates.
Use Cases:
- Transaction created/completed → Update POS display
- Return processed → Update inventory availability
- Payment processed → Close transaction screen
Implementation:
transaction.on_commit(
lambda: WebSocketBroadcaster.broadcast_transaction_completed(
transaction_instance,
store_id,
payment_method
)
)
Sync Flow Example
Complete Transaction Creation Flow:
-
Transaction Created (status='pending'):
- Signal triggered → Skipped (pending status)
- Remains local
-
Line Items Added:
- Signals triggered → Deferred (parent has no sanity_id)
- Snapshots captured
-
Inventory Reserved:
process_inventory_movement_from_sale_item→ Async task queued- InventoryLevel updated
-
Payment Applied:
- Signal triggered → Skipped (created=True, parent pending)
- Payment record created locally
-
Transaction Completed (status='completed'):
sync_sale_transaction_to_sanity→ Async task queued- Transaction synced to Sanity → Gets sanity_id
- WebSocket broadcast → POS terminals notified
-
Customer Updated:
update_customer_on_transaction_complete→ Async task queued- Order history and loyalty points updated
-
Child Records Synced:
- Line items checked → Now can sync (parent has sanity_id)
- Payments checked → Now can sync (parent completed)
- Both sync via async tasks
Result: Complete, consistent transaction in both Django and Sanity.
Related Documentation
- Models - Transaction data models
- Serializers - API serialization
- Views - API endpoints
- Services - Business logic services
- Integrations - Sync infrastructure and Celery tasks