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

  1. Overview
  2. Transaction Signals
  3. Payment Signals
  4. Line Item Signals
  5. Return & Credit Signals
  6. Deletion Signals
  7. 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:

  1. sync_sale_transaction_to_sanity - Sync completed transactions
  2. update_customer_on_transaction_complete - Update customer data
  3. sync_payment_to_sanity - Sync payments
  4. sync_transaction_item_to_sanity - Sync line items with race condition prevention
  5. process_inventory_movement_from_sale_item - Process inventory
  6. release_inventory_on_line_item_delete - Release inventory on deletion
  7. sync_return_to_sanity - Sync returns
  8. sync_store_credit_to_sanity - Sync store credits
  9. sync_payment_method_to_sanity - Sync payment methods
  10. delete_sale_transaction_from_sanity - Handle transaction deletion
  11. delete_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):

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

  1. 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:

  1. Payment created → Signal skips (parent pending)
  2. Transaction completed → Transaction synced to Sanity
  3. 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:

  1. Line item created during transaction building → Deferred (parent has no sanity_id yet)
  2. Transaction completed → Transaction synced to Sanity (gets sanity_id)
  3. 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 InventoryMovement record
  • Updates InventoryLevel quantities
  • 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):

  1. Mark Payment as Deleted:
if hasattr(instance, 'sanity_id') and instance.sanity_id:
    logger.info(f"Marked Payment {instance.pk} as deleted in Sanity")
  1. 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
)

_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 transaction
  • broadcast_transaction_completed: Transaction completed with payment method
  • broadcast_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:

  1. Transaction Created (status='pending'):

    • Signal triggered → Skipped (pending status)
    • Remains local
  2. Line Items Added:

    • Signals triggered → Deferred (parent has no sanity_id)
    • Snapshots captured
  3. Inventory Reserved:

    • process_inventory_movement_from_sale_item → Async task queued
    • InventoryLevel updated
  4. Payment Applied:

    • Signal triggered → Skipped (created=True, parent pending)
    • Payment record created locally
  5. Transaction Completed (status='completed'):

    • sync_sale_transaction_to_sanity → Async task queued
    • Transaction synced to Sanity → Gets sanity_id
    • WebSocket broadcast → POS terminals notified
  6. Customer Updated:

    • update_customer_on_transaction_complete → Async task queued
    • Order history and loyalty points updated
  7. 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.


Was this page helpful?