Transactions Domain - Models Documentation

Complete documentation of Transactions domain data models.

Source: api/nextango/apps/transactions/models.py Last Updated: 2026-07-08


Table of Contents

  1. Overview
  2. Custom Managers
  3. Security & Audit Models
  4. Shopping Cart Models
  5. Core Transaction Models
  6. Field Reference Tables

Overview

The Transactions domain handles all sales, returns, payments, and cash drawer operations in the Nextango e-commerce system.

Model Categories:

  • Custom Managers (3): SaleTransactionManager, ReturnManager, StoreCreditManager
  • Security & Audit (5): TransactionAuditLog, PaymentAttempt, IdempotencyRecord, FraudDetectionResult, TransactionLock
  • Shopping Cart (1): Cart
  • Core Transactions (7): SaleTransaction, Return, StoreCredit, CashDrawerEvent, SaleLineItem, StoreCreditTransaction, ReturnLineItem
  • Fulfillment (3): Fulfillment, Shipment, ShipmentLineItem

Key Architectural Patterns:

  • Relational Line Items: Moved from JSONField to relational models (SaleLineItem, ReturnLineItem)
  • Immutable Snapshots: Historical product data captured at transaction time
  • Audit Trail: TransactionAuditLog records all operations
  • Idempotency: IdempotencyRecord prevents duplicate operations
  • Promotional Integration: PromotionService integration for discount application
  • Bidirectional Sync: SanityIntegratedModel integration for real-time sync

Custom Managers

SaleTransactionManager

Purpose: Factory methods for creating sale transactions with auto-generated IDs.

Source: models.py:24-39

Class Definition:

class SaleTransactionManager(models.Manager):
    """
    Custom manager for the SaleTransaction model.
    """

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

Creates a new sale transaction with auto-generated transaction ID.

Source: models.py:28-39

Signature:

def create_transaction(self, store, employee=None, customer=None):

Parameters:

  • store (Store): Store where transaction occurs (required)
  • employee (User): Employee processing transaction (optional)
  • customer (Customer): Customer making purchase (optional)

Returns: SaleTransaction instance with auto-generated transactionId

Transaction ID Format: TXN-{YYYYMMDD}-{8-char-hex-uuid}

Example:

from nextango.apps.stores.models import Store
from nextango.apps.transactions.models import SaleTransaction

store = Store.objects.get(store_code='COL001')
transaction = SaleTransaction.objects.create_transaction(store=store)
# Result: transactionId = 'TXN-20251117-A3B4C5D6'

Implementation:

def create_transaction(self, store, employee=None, customer=None):
    transaction_id = f"TXN-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
    return self.create(
        transactionId=transaction_id,
        store=store,
        employee=employee,
        customer=customer,
        status='pending'
    )

ReturnManager

Purpose: Factory methods for creating return records with auto-generated IDs.

Source: models.py:41-57

Class Definition:

class ReturnManager(models.Manager):
    """
    Custom manager for the Return model.
    """

create_return(original_transaction, employee, reason="")

Creates a new return record with auto-generated return ID.

Source: models.py:45-57

Signature:

def create_return(self, original_transaction, employee, reason=""):

Parameters:

  • original_transaction (SaleTransaction): Original sale being returned
  • employee (User): Employee processing the return
  • reason (str): Reason for return (optional)

Returns: Return instance with auto-generated returnId

Return ID Format: RET-{YYYYMMDD}-{8-char-hex-uuid}

Example:

from nextango.apps.transactions.models import Return

return_record = Return.objects.create_return(
    original_transaction=transaction,
    employee=employee,
    reason="Customer requested refund"
)
# Result: returnId = 'RET-20251117-F7E8D9C0'

Implementation:

def create_return(self, original_transaction, employee, reason=""):
    return_id = f"RET-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
    return self.create(
        returnId=return_id,
        originalSaleId=original_transaction,
        employeeId=employee,
        reason=reason,
        status='pending'
    )

StoreCreditManager

Purpose: Factory methods for creating store credit records with auto-generated IDs and expiration dates.

Source: models.py:59-77

Class Definition:

class StoreCreditManager(models.Manager):
    """
    Custom manager for the StoreCredit model.
    """

create_store_credit(customer, amount, expiration_days=365, source_return=None)

Creates a new store credit record with auto-generated credit ID.

Source: models.py:63-77

Signature:

def create_store_credit(self, customer, amount, expiration_days=365, source_return=None):

Parameters:

  • customer (Customer): Customer receiving credit (required)
  • amount (Decimal): Initial credit amount (required)
  • expiration_days (int): Days until expiration (default: 365)
  • source_return (Return): Return that generated this credit (optional)

Returns: StoreCredit instance with auto-generated creditId

Credit ID Format: SC-{YYYYMMDD}-{8-char-hex-uuid}

Expiration Calculation: current_date + expiration_days

Example:

from decimal import Decimal
from nextango.apps.transactions.models import StoreCredit

store_credit = StoreCredit.objects.create_store_credit(
    customer=customer,
    amount=Decimal('50.00'),
    expiration_days=365,
    source_return=return_record
)
# Result: creditId = 'SC-20251117-B1C2D3E4'
#         expirationDate = 2026-11-17

Implementation:

def create_store_credit(self, customer, amount, expiration_days=365, source_return=None):
    credit_id = f"SC-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
    expiration_date = timezone.now().date() + timezone.timedelta(days=expiration_days)

    return self.create(
        creditId=credit_id,
        customerId=customer,
        initialAmount=amount,
        remainingBalance=amount,
        expirationDate=expiration_date,
        sourceReturn=source_return
    )

Security & Audit Models

These models do NOT sync with Sanity - they are internal security and audit mechanisms.


TransactionAuditLog

Purpose: Immutable audit log for all transaction operations.

Source: models.py:83-129

Inheritance: AuditableMixin

Class Definition:

class TransactionAuditLog(AuditableMixin):
    """
    Immutable audit log for all transaction operations
    """

Action Choices

Source: models.py:87-98

class Action(models.TextChoices):
    CREATED = 'created', 'Transaction Created'
    ITEM_ADDED = 'item_added', 'Item Added'
    ITEM_REMOVED = 'item_removed', 'Item Removed'
    ITEM_UPDATED = 'item_updated', 'Item Updated'
    DISCOUNT_APPLIED = 'discount_applied', 'Discount Applied'
    PAYMENT_PROCESSED = 'payment_processed', 'Payment Processed'
    PAYMENT_FAILED = 'payment_failed', 'Payment Failed'
    TRANSACTION_COMPLETED = 'transaction_completed', 'Transaction Completed'
    TRANSACTION_VOIDED = 'transaction_voided', 'Transaction Voided'
    REFUND_PROCESSED = 'refund_processed', 'Refund Processed'

Usage: Tracks 10 different transaction lifecycle events.


Fields

FieldTypeConstraintsDescription
audit_idUUIDFieldunique, autoUnique audit log entry ID
transaction_idCharField(100)indexedTransaction identifier
actionCharField(50)choices=ActionType of action performed
actor_typeCharField(20)-Actor category ('employee', 'customer', 'system')
actor_idPositiveIntegerField-ID of actor
actor_nameCharField(255)-Name of actor for readability
data_beforeJSONFielddefault=dictSnapshot before action
data_afterJSONFielddefault=dictSnapshot after action
timestampDateTimeFieldauto_now_addWhen action occurred
ip_addressGenericIPAddressField-IP address of request
user_agentTextFieldblankBrowser/client user agent
session_idCharField(40)blankSession identifier
reasonTextFieldblankExplanation for action
metadataJSONFielddefault=dictAdditional context

Source: models.py:99-118


Meta Configuration

Source: models.py:120-126

class Meta:
    ordering = ['-timestamp']
    indexes = [
        models.Index(fields=['transaction_id', 'timestamp']),
        models.Index(fields=['actor_id', 'actor_type']),
        models.Index(fields=['action', 'timestamp']),
    ]

Indexes: 3 composite indexes for efficient audit trail queries.


PaymentAttempt

Purpose: Track all payment attempts for debugging and security.

Source: models.py:132-180

Class Definition:

class PaymentAttempt(models.Model):
    """
    Track all payment attempts for debugging and security
    """

Status Choices

Source: models.py:136-141

class Status(models.TextChoices):
    INITIATED = 'initiated', 'Initiated'
    PROCESSING = 'processing', 'Processing'
    SUCCEEDED = 'succeeded', 'Succeeded'
    FAILED = 'failed', 'Failed'
    CANCELLED = 'cancelled', 'Cancelled'
    EXPIRED = 'expired', 'Expired'

Usage: Tracks 6 possible payment attempt states.


Fields

FieldTypeConstraintsDescription
attempt_idUUIDFieldunique, autoUnique attempt identifier
paymentForeignKeyCASCADEReference to TransactionPayment
attempt_numberPositiveIntegerFieldunique_togetherAttempt sequence number
statusCharField(20)choices=Status, default=INITIATEDCurrent attempt status
attempted_amountDecimalField(10,2)-Amount attempted
processor_referenceCharField(255)blankExternal processor reference
initiated_atDateTimeFieldauto_now_addWhen attempt started
completed_atDateTimeFieldnull, blankWhen attempt finished
expires_atDateTimeFieldauto-calculatedWhen attempt expires (15 min)
error_codeCharField(50)blankError code if failed
error_messageTextFieldblankError description
processor_responseJSONFielddefault=dictFull processor response
ip_addressGenericIPAddressField-Request IP address
fraud_scoreDecimalField(5,2)null, blankFraud detection score
risk_factorsJSONFielddefault=listDetected risk factors

Source: models.py:144-167


Auto-Expiration Logic

Source: models.py:177-180

def save(self, *args, **kwargs):
    if not self.expires_at:
        self.expires_at = timezone.now() + timezone.timedelta(minutes=15)
    super().save(*args, **kwargs)

Behavior: Payment attempts automatically expire 15 minutes after initiation if not explicitly set.


IdempotencyRecord

Purpose: Track idempotency keys to prevent duplicate operations.

Source: models.py:183-218

Class Definition:

class IdempotencyRecord(models.Model):
    """
    Track idempotency keys to prevent duplicate operations
    """

Fields

FieldTypeConstraintsDescription
idempotency_keyCharField(255)unique, indexedClient-provided idempotency key
request_hashCharField(64)-SHA256 hash of request body
operation_typeCharField(50)-Type of operation
resource_typeCharField(50)-Type of resource affected
resource_idCharField(100)-ID of affected resource
response_statusPositiveIntegerField-HTTP status code returned
response_dataJSONField-Cached response data
created_atDateTimeFieldauto_now_addWhen request first processed
expires_atDateTimeFieldauto-calculatedWhen cache expires (1 hour)
user_idPositiveIntegerFieldnull, blankUser who made request
ip_addressGenericIPAddressField-Request IP address

Source: models.py:187-203


Expiration Logic

Source: models.py:212-218

def save(self, *args, **kwargs):
    if not self.expires_at:
        self.expires_at = timezone.now() + timezone.timedelta(hours=1)
    super().save(*args, **kwargs)

def is_expired(self):
    return timezone.now() > self.expires_at

Behavior:

  • Idempotency records expire 1 hour after creation
  • is_expired() property for easy checking

Use Case: Prevent duplicate payment processing if user clicks "Submit" multiple times.


FraudDetectionResult

Purpose: Store fraud detection results for payments.

Source: models.py:221-267

Class Definition:

class FraudDetectionResult(models.Model):
    """
    Store fraud detection results for payments
    """

Risk Level Choices

Source: models.py:225-229

class RiskLevel(models.TextChoices):
    LOW = 'low', 'Low Risk'
    MEDIUM = 'medium', 'Medium Risk'
    HIGH = 'high', 'High Risk'
    BLOCKED = 'blocked', 'Blocked'

Status Choices

Source: models.py:231-235

class Status(models.TextChoices):
    PENDING = 'pending', 'Pending Review'
    APPROVED = 'approved', 'Approved'
    DECLINED = 'declined', 'Declined'
    MANUAL_REVIEW = 'manual_review', 'Manual Review Required'

Fields

FieldTypeConstraintsDescription
paymentOneToOneFieldCASCADEPayment being analyzed
risk_levelCharField(20)choices=RiskLevelOverall risk assessment
risk_scoreDecimalField(5,2)-Numeric risk score (0-100)
statusCharField(20)choices=Status, default=PENDINGReview status
risk_factorsJSONFielddefault=listDetected risk indicators
detection_rules_triggeredJSONFielddefault=listRules that fired
ml_model_resultsJSONFielddefault=dictML model outputs
device_fingerprintCharField(64)blankDevice identifier hash
velocity_checksJSONFielddefault=dictVelocity analysis results
geolocation_dataJSONFielddefault=dictIP geolocation info
analyzed_atDateTimeFieldauto_now_addWhen analysis completed
reviewed_atDateTimeFieldnull, blankWhen manually reviewed
reviewed_byForeignKey(User)SET_NULLUser who reviewed
external_fraud_checksJSONFielddefault=dictThird-party service results

Source: models.py:237-260

Relationship: OneToOne with TransactionPayment (one fraud check per payment).


TransactionLock

Purpose: Prevent concurrent modifications to transactions.

Source: models.py:270-291

Class Definition:

class TransactionLock(models.Model):
    """
    Prevent concurrent modifications to transactions
    """

Fields

FieldTypeConstraintsDescription
transaction_idCharField(100)uniqueTransaction being locked
locked_byForeignKey(User)CASCADEUser holding the lock
locked_atDateTimeFieldauto_now_addWhen lock acquired
expires_atDateTimeFieldauto-calculatedWhen lock expires (5 min)
operationCharField(50)-Type of operation in progress

Source: models.py:274-278


Lock Expiration Logic

Source: models.py:285-291

def save(self, *args, **kwargs):
    if not self.expires_at:
        self.expires_at = timezone.now() + timezone.timedelta(minutes=5)
    super().save(*args, **kwargs)

def is_expired(self):
    return timezone.now() > self.expires_at

Behavior:

  • Locks automatically expire 5 minutes after acquisition
  • is_expired() property for checking validity

Use Case: Prevent race conditions when multiple employees try to modify the same transaction.


Shopping Cart Models

Cart

Purpose: Represents a shopping cart for customers or guest users.

Source: models.py:297-343

Inheritance: SanityIntegratedModel

Class Definition:

class Cart(SanityIntegratedModel):
    """
    Represents a shopping cart for a customer or guest user.
    """

Status Choices

Source: models.py:301-304

class Status(models.TextChoices):
    ACTIVE = 'active', 'Active'
    MERGED = 'merged', 'Merged'
    COMPLETED = 'completed', 'Completed'

Usage:

  • ACTIVE: Currently being used
  • MERGED: Guest cart merged into customer cart on login
  • COMPLETED: Converted to SaleTransaction

Fields

FieldTypeConstraintsDescription
cartIdUUIDFieldunique, indexed, db_column='cart_id'Auto-generated cart identifier
customerForeignKey(Customer)CASCADE, null, blankCart owner (null for guest)
statusCharField(20)choices=Status, default=ACTIVE, indexedCart state
line_itemsJSONFielddefault=list, blankArray of cart line items

Source: models.py:306-311

Inherited from SanityIntegratedModel:

  • sanity_id, last_synced_at, sync_enabled, created_at, updated_at

Meta Configuration

Source: models.py:313-321

class Meta:
    ordering = ['-updated_at']
    indexes = [
        models.Index(fields=['customer', 'status']),
        models.Index(fields=['created_at']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
    ]

Indexes: 5 indexes optimized for cart retrieval and sync queries.


Properties

item_count Property

Source: models.py:328-333

@property
def item_count(self):
    """Returns the total number of items in the cart."""
    if isinstance(self.line_items, list):
        return sum(item.get('quantity', 0) for item in self.line_items)
    return 0

Behavior: Sums quantity across all line items in JSONField array.


is_empty Property

Source: models.py:335-337

@property
def is_empty(self):
    """Returns True if the cart has no items."""
    return self.item_count == 0

Methods

clear() Method

Source: models.py:340-343

def clear(self):
    """Removes all items from the cart."""
    self.line_items = []
    self.save(update_fields=['line_items', 'updated_at'])

Behavior: Empties cart and updates database with only affected fields.


Line Items Structure

JSONField Format:

line_items = [
    {
        'product_variant_id': 'uuid-here',
        'quantity': 2,
        'unit_price': '29.99',
        'total_price': '59.98'
    },
    {
        'product_variant_id': 'uuid-here-2',
        'quantity': 1,
        'unit_price': '49.99',
        'total_price': '49.99'
    }
]

Core Transaction Models

SaleTransaction

Purpose: Represents a single sale transaction in a store.

Source: models.py:350-597

Inheritance: SanityIntegratedModel, AuditableMixin

Custom Manager: SaleTransactionManager

Class Definition:

class SaleTransaction(SanityIntegratedModel, AuditableMixin):
    """
    Represents a single sale transaction in a store.
    Inherits from SanityIntegratedModel for sync fields and AuditableMixin for audit fields.
    """

Status Choices

Source: models.py:389-397

class Status(models.TextChoices):
    PENDING = 'pending', 'Pending'
    PARTIALLY_PAID = 'partially_paid', 'Partially Paid'
    COMPLETED = 'completed', 'Completed'
    VOIDED = 'voided', 'Voided'
    REFUNDED = 'refunded', 'Refunded'
    PARTIALLY_REFUNDED = 'partially_refunded', 'Partially Refunded'
    CANCELLED = 'cancelled', 'Cancelled'  # Customer-facing cancellation (pickup orders)
    PAYMENT_REQUIRES_ACTION = 'payment_requires_action', 'Payment Requires Action'  # 3DS / SCA pending

Lifecycle: PENDINGPARTIALLY_PAIDCOMPLETEDREFUNDED/PARTIALLY_REFUNDED or VOIDED. Pickup orders can reach CANCELLED. Card payments requiring 3D Secure / Strong Customer Authentication can hold at PAYMENT_REQUIRES_ACTION before resolving to COMPLETED or being voided by cleanup.

StatusDescription
PENDINGTransaction created; no payment applied yet
PARTIALLY_PAIDIntermediate state: partial payment applied, balance still outstanding. Set by PaymentService.apply_payment_to_transaction() when tendered amount does not cover the full balance. Layaway and pay-at-pickup deposits land here. The active-orders queue surfaces these alongside PENDING transactions. Abandoned rows older than the configured threshold are voided by the cleanup_stale_partial_payments task (see tasks.md).
COMPLETEDFully paid. Set automatically when payment_balance reaches zero.
VOIDEDCancelled by staff (manager override may be required). Distinct from customer cancellation.
REFUNDEDFully refunded after completion.
PARTIALLY_REFUNDEDOne or more line items refunded; remainder still settled.
CANCELLEDCustomer-facing cancellation (distinct from staff VOID). Set with cancelledAt timestamp, cancellationReason text, and cancelledBy FK recording the cancelling user. Triggered via pickup cancellation flow or customer order cancellation. Inventory is released on cancellation.
PAYMENT_REQUIRES_ACTIONCard payment pending 3D Secure / Strong Customer Authentication. Abandoned rows older than the configured threshold are voided by the cleanup_stale_3ds_payments task.

Client note: Clients filtering transactions by status must account for all 8 states, including PARTIALLY_PAID, CANCELLED, and PAYMENT_REQUIRES_ACTION. Querying only for pending or completed will miss active partially-paid transactions, cancelled orders, and payments pending authentication.


Source System Choices

Source: models.py:490-501

class SourceSystem(models.TextChoices):
    POS = 'pos', 'POS System'
    WEB_COMMERCE = 'web_commerce', 'Web Commerce'
    DASHBOARD = 'dashboard', 'Admin Dashboard'
    API = 'api', 'Direct API'

source_system records which channel created the transaction and controls inventory handling and sync behavior for that channel. Passed through TransactionService.create_transaction(store, employee=None, customer=None, source_system=None) (services/transaction_service.py:41-47); defaults to POS when omitted.


Transaction Type Is Not A Field

transaction_type is not a field on SaleTransaction. It lives on the active vertical's transaction compliance profile and is read via transaction.compliance_profile.transaction_type (models.py:544-546). SaleTransactionSerializer exposes a transaction_type field for API backward compatibility, computed by the compliance seam rather than stored on this model. See Serializers.


Fulfillment Method Choices

Source: models.py:393-397

class FulfillmentMethod(models.TextChoices):
    PICKUP = 'pickup', 'In-Store Pickup'
    SHIP = 'ship', 'Ship to Customer'
    DIGITAL = 'digital', 'Digital Delivery'
    COUNTER_SALE = 'counter_sale', 'Counter Sale'

Usage: Critical for e-commerce expansion - drives inventory, returns, and delivery logic.


Fields

FieldTypeConstraintsDB ColumnDescription
transactionIdCharField(100)unique, indexedtransaction_idAuto-generated transaction ID
storeForeignKey(Store)PROTECT-Store where sale occurred
employeeForeignKey(User)SET_NULL, null, blank-Employee who processed sale
customerForeignKey(Customer)SET_NULL, null, blank-Customer who made purchase
subtotalDecimalField(10,2)default=0.00-Total before tax and discounts
taxAmountDecimalField(10,2)default=0.00tax_amountTax applied
tax_breakdownJSONFielddefault=list, blank-Per-tier tax snapshot, rebuilt on every recalculate_totals() call. List of objects with keys name, type, rate, appliesTo, isActive, and amount (all strings/floats except amount, a string). Empty list [] when the store's jurisdiction is tax-exempt or unconfigured.
appliedPromotionDiscountDecimalField(10,2)default=0.00-Total automatic promotion discount applied at checkout. Separate from orderLevelDiscount which is promo-code-only.
totalAmountDecimalField(10,2)default=0.00total_amountFinal total after tax
orderLevelDiscountDecimalField(10,2)default=0.00-Promotional discount applied
appliedPromoCodesManyToManyFieldblank-Promo codes used
sourceCheckoutIdTextFieldblank-Originating cart/checkout ID
fulfillmentOneToOneField(Fulfillment)SET_NULL, null, blank-Fulfillment record holding the checkout form data snapshot (shipping/billing address, delivery method).
fulfillmentMethodCharField(20)choices, default=COUNTER_SALE, indexedfulfillment_methodHow order fulfilled
payment_timingCharField(20)choices, default=IMMEDIATE, indexed-When payment is collected. Choices: immediate ("Pay Now"), at_pickup ("Pay at Store Pickup").
source_systemCharField(20)choices, default=POS, indexed-Channel that created the transaction: pos, web_commerce, dashboard, or api. Controls inventory handling and sync behavior for that channel.
statusCharField(30)choices, default=PENDING, indexed-Transaction status
timestampDateTimeFieldauto_now_add, editable=False-When transaction created
completedAtDateTimeFieldnull, blankcompleted_atWhen transaction completed
voidedAtDateTimeFieldnull, blankvoided_atWhen transaction voided
voidReasonTextFieldblankvoid_reasonWhy transaction voided
ab_test_variantCharField(1)null, blankab_test_variantA/B test bucket assigned at checkout (a or b)
guest_access_tokenCharField(64)unique, indexed, null, blank-Secure token for guest order tracking (web commerce only)
cancelledAtDateTimeFieldnull, blankcancelled_atWhen transaction was cancelled
cancellationReasonTextFieldblankcancellation_reasonWhy transaction was cancelled
cancelledByForeignKey(User)SET_NULL, null, blank-User who cancelled (customer or staff)
expires_atDateTimeFieldnull, blankexpires_atExpiry datetime for layaway and pay-at-store transactions. Populated by CartService or POS on creation. null for counter-sale transactions.
notesTextFieldblank=True, default=""-Internal staff notes on this transaction. Optional; never null. Writable on POST/PUT/PATCH; returned in all GET responses.

Source: models.py:384-524

Important: Line items are NO LONGER stored in JSONField - now relational via line_items reverse relationship.


Meta Configuration

Source: models.py:550-568

class Meta:
    indexes = [
        models.Index(fields=['timestamp', 'store']),
        models.Index(fields=['customer', 'timestamp']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
        models.Index(fields=['guest_access_token']),
        models.Index(
            fields=['voidedAt'],
            name='sale_txn_void_reconcile_idx',
            condition=models.Q(status='voided'),
        ),
    ]
    ordering = ['-timestamp']

Indexes: 7 indexes for efficient transaction queries, sync operations, and guest order lookup. The last index is a partial index (condition=Q(status='voided')) scoped to voided rows only, so its size stays bounded as historical-row volume grows; it backs the void-reconciliation worker's scan of voided transactions by voidedAt (see tasks.md).


clean() Validation

Source: models.py:431-438

def clean(self):
    """
    Custom model validation.
    """
    if self.status == self.Status.COMPLETED and not self.completedAt:
        self.completedAt = timezone.now()
    if self.status == self.Status.VOIDED and not self.voidedAt:
        self.voidedAt = timezone.now()

Behavior: Auto-sets timestamp fields when status changes.


save() Override

Source: models.py:440-442

def save(self, *args, **kwargs):
    self.full_clean()
    super().save(*args, **kwargs)

Behavior: Always runs validation before saving.


recalculate_totals() Method

Purpose: Recalculates subtotal, tax, and total from relational line items, integrating both the promotional engine and the active vertical's tax and excise rules.

Source: models.py:647-782

Signature:

def recalculate_totals(self, commit=True):

Parameters:

  • commit (bool): If True, saves changes to database (default: True)

Implementation Flow:

  1. Calculate base subtotal from line items, falling back to the current subtotal when there are none yet.

  2. Integrate with PromotionService.evaluate_transaction() to set orderLevelDiscount. On success this also syncs per-line-item promotional discount breakdowns via _apply_promotion_discounts_to_line_items(). On ImportError or any other exception it logs and falls back to zero discount rather than breaking the transaction.

  3. Resolve the base tax rate via TaxClassificationService().calculate_base_rate(self) (nextango.apps.compliance.services.tax_classification). If the store's tax jurisdiction is explicitly marked exempt, the rate is 0.00 with no warning. If the store has no jurisdiction configured at all, DEFAULT_TAX_RATE_FALLBACK (from settings) is used and a warning is logged.

  4. Compute the taxable subtotal by excluding line items where isTaxExempt=True, then apply the order-level discount proportionally to that taxable portion before computing base_tax.

  5. Add vertical excise on top of base tax: via the agnostic seam:

from nextango.apps.compliance.services.vertical_provider import get_active_vertical_provider
excise_components = get_active_vertical_provider().additional_excise(
    self, taxable_line_items, self.orderLevelDiscount, base_subtotal,
)

Each component in excise_components is a dict already carrying its own computed amount. self.taxAmount is base_tax + sum(excise amounts). The active vertical owns the excise structure entirely; the agnostic model only sums what it returns. A vertical with no additional excise (the default agnostic case) returns an empty list.

  1. Build tax_breakdown via _build_tax_breakdown(base_tax, excise_components): see below.

  2. Set totalAmount to the discounted subtotal plus taxAmount, and save the affected fields (subtotal, taxAmount, totalAmount, orderLevelDiscount, tax_breakdown) when commit=True.

Error Handling: Gracefully falls back to no promotions if PromotionService is unavailable or raises.


_build_tax_breakdown(base_tax, excise_components) Method

Source: models.py:593-645

Purpose: Assembles the tax_breakdown list. Fetches the base rate components from TaxClassificationService (already filtered to the applicable tier) and splits base_tax proportionally across them, with the last base component absorbing any rounding residual. The pre-computed excise_components passed in by recalculate_totals are appended verbatim, each keeping the amount the active vertical already calculated for it.

Returns: A list of dicts, each {'name': str, 'type': str, 'rate': float, 'appliesTo': str, 'isActive': bool, 'amount': str}. Returns [] on any error or when there are no base or excise components (for example, a fully tax-exempt jurisdiction).


calculate_tax() Method

Source: models.py:820-825

def calculate_tax(self) -> Decimal:
    """
    Calculates tax based on the store's tax rate.
    """
    tax_rate = getattr(self.store, 'tax_rate', Decimal('0.08'))
    return (self.subtotal * tax_rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)

Behavior: Uses store's tax rate (default 8%) with banker's rounding.


Properties

payment_balance Property

Source: models.py:549-555

@property
def payment_balance(self) -> Decimal:
    """
    Calculates the remaining balance to be paid.
    """
    total_paid = self.payment_records.filter(status='succeeded').aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
    return self.totalAmount - total_paid

Behavior: Returns amount still owed on transaction.


is_fully_paid Property

Source: models.py:557-562

@property
def is_fully_paid(self) -> bool:
    """
    Checks if the transaction is fully paid.
    """
    return self.payment_balance <= 0

get_refundable_amount Property

Source: models.py:564-589

@property
def get_refundable_amount(self) -> Decimal:
    """
    Calculates the amount that can still be refunded.
    """
    if self.status not in [self.Status.COMPLETED, self.Status.PARTIALLY_REFUNDED]:
        return Decimal('0.00')

    # Get all processed returns for this transaction
    processed_returns = self.returns.filter(status=Return.Status.PROCESSED)

    # Sum refund amounts from all refund records related to these returns
    total_refunds = Decimal('0.00')
    for return_record in processed_returns:
        refund_total = return_record.refunds.aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
        total_refunds += refund_total

    # Sum store credit amounts from all store credit transactions related to these returns
    total_store_credits = Decimal('0.00')
    for return_record in processed_returns:
        credit_total = return_record.store_credit_transactions.aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')
        total_store_credits += credit_total

    total_refunded = total_refunds + total_store_credits

    return max(Decimal('0.00'), self.totalAmount - total_refunded)

Behavior: Calculates remaining refundable amount considering both cash refunds and store credits.


can_be_returned Property

Source: models.py:591-596

@property
def can_be_returned(self) -> bool:
    """
    Determines if the transaction can be returned.
    """
    return self.status in [self.Status.COMPLETED, self.Status.PARTIALLY_REFUNDED] and self.get_refundable_amount > 0

Behavior: Returns True only if transaction is completed/partially refunded AND has remaining refundable amount.


Return

Purpose: Represents a return of items from a completed sale.

Source: models.py:599-708

Inheritance: SanityIntegratedModel, AuditableMixin

Custom Manager: ReturnManager

Class Definition:

class Return(SanityIntegratedModel, AuditableMixin):
    """
    Represents a return of items from a completed sale.
    Now uses relational SalesReturnItem model instead of JSON array.
    """

Status Choices

Source: models.py:605-608

class Status(models.TextChoices):
    PENDING = 'pending', 'Pending'
    PROCESSED = 'processed', 'Processed'
    CANCELLED = 'cancelled', 'Cancelled'

Lifecycle: PENDINGPROCESSED or CANCELLED


Fields

FieldTypeConstraintsDescription
returnIdCharField(100)unique, indexedAuto-generated return ID
originalSaleIdForeignKey(SaleTransaction)PROTECTOriginal sale being returned
reasonTextFieldblankReason for return
statusCharField(20)choices, default=PENDING, indexedReturn status
employeeIdForeignKey(User)SET_NULL, null, blankEmployee who initiated return
processedByForeignKey(User)SET_NULL, null, blankEmployee who processed return
timestampDateTimeFieldauto_now_add, editable=FalseWhen return created
processedAtDateTimeFieldnull, blankWhen return processed

Source: models.py:610-649

Removed Fields (as of November 2025):

  • items JSONField - Now using relational ReturnLineItem model (accessed via return_line_items related manager)
  • refund_amount DecimalField - Removed (refunds now tracked via Refund.sales_return FK in payments app)
  • store_credit_issued DecimalField - Removed (store credits now linked via reference arrays)

Important Architectural Changes:

  1. Relational Line Items: Return items are NO LONGER stored in JSONField - now relational via return_line_items reverse relationship.

  2. Refund-First Architecture: Refunds are processed BEFORE the Return record is created, then linked afterward:

    • Refunds created with sales_return=None initially
    • Return record created after refunds processed
    • Refunds updated to link to Return via Refund.sales_return FK
    • See services.md for implementation details
  3. No Financial Amount Fields: Return model no longer stores refund_amount or store_credit_issued. These are calculated from related Refund and StoreCredit records.


Properties

customer Property

Source: models.py:651-654

@property
def customer(self):
    """Get customer from original transaction"""
    return self.originalSaleId.customer if self.originalSaleId else None

Behavior: Delegates to original transaction's customer.


isProcessed Property

Source: models.py:656-659

@property
def isProcessed(self):
    """Boolean representation of processed status for Sanity sync"""
    return self.status == self.Status.PROCESSED

Purpose: Provides boolean field for Sanity sync (matches Sanity schema).


Methods

total_return_value Property

Source: models.py:847-857

@property
def total_return_value(self) -> Decimal:
    """
    Total value returned to customer: sum of succeeded refunds + store credit issued.
    """
    refund_total = self.refunds.filter(status='succeeded').aggregate(
        total=Sum('amount')
    )['total'] or Decimal('0.00')
    credit_total = self.store_credit_transactions.aggregate(
        total=Sum('amount')
    )['total'] or Decimal('0.00')
    return refund_total + credit_total

Behavior: Sums succeeded Refund.amount values plus linked StoreCreditTransaction.amount values. This replaces the previous get_total_return_value() method that summed ReturnLineItem.returnPrice: the new calculation reflects actual money returned to the customer rather than the original item value.


mark_as_processed() Method

Source: models.py:674-679

def mark_as_processed(self, processed_by_employee):
    """Mark the return as processed"""
    self.status = self.Status.PROCESSED
    self.processedBy = processed_by_employee
    self.processedAt = timezone.now()
    self.save()

Behavior: Sets status to PROCESSED and records who processed it and when.


clean() Validation

Source: models.py:695-699

def clean(self):
    if self.pk is None:
        refundable = self.originalSaleId.get_refundable_amount()
        if self.total_return_value > refundable:
            raise ValidationError(f"Return amount (\${self.total_return_value}) exceeds refundable amount (\${refundable}).")

Behavior: Prevents creating returns that exceed refundable amount.


StoreCredit

Purpose: Represents store credit issued to a customer.

Source: models.py:711-789

Inheritance: SanityIntegratedModel, AuditableMixin

Custom Manager: StoreCreditManager

Class Definition:

class StoreCredit(SanityIntegratedModel, AuditableMixin):
    """
    Represents store credit issued to a customer.
    """

Fields

FieldTypeConstraintsDescription
creditIdCharField(100)unique, indexedAuto-generated credit ID
customerIdForeignKey(Customer)CASCADECustomer who owns credit
initialAmountDecimalField(10,2)-Original credit amount
issuedDateDateFieldnull, blankWhen credit issued
expirationDateDateFieldnull, blank, indexedWhen credit expires
isActiveBooleanFielddefault=TrueWhether credit can be used
remainingBalanceDecimalField(10,2)editable=FalseCurrent balance (computed)
sourceReturnForeignKey(Return)SET_NULL, null, blankReturn that generated credit

Source: models.py:715-722


Meta Configuration

Source: models.py:726-737

class Meta:
    constraints = [
        models.CheckConstraint(check=models.Q(initialAmount__gt=0), name='positive_credit_amount'),
        models.CheckConstraint(check=models.Q(remainingBalance__gte=0), name='non_negative_balance'),
        models.CheckConstraint(check=models.Q(remainingBalance__lte=models.F('initialAmount')), name='balance_not_exceed_amount'),
    ]
    ordering = ['-created_at']
    indexes = [
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
    ]

Database Constraints:

  • initialAmount must be positive
  • remainingBalance must be non-negative
  • remainingBalance cannot exceed initialAmount

clean() Validation

Source: models.py:742-746

def clean(self):
    if self.remainingBalance > self.initialAmount:
        raise ValidationError("Remaining balance cannot exceed the original amount.")
    if self.remainingBalance <= 0:
        self.isActive = False

Behavior: Auto-deactivates credit when balance reaches zero.


Methods

is_expired() Method

Source: models.py:748-754

def is_expired(self) -> bool:
    """
    Checks if the store credit has expired.
    """
    if not self.expirationDate:
        return False
    return timezone.now().date() > self.expirationDate

is_usable Property

Source: models.py:756-761

@property
def is_usable(self) -> bool:
    """
    Determines if the store credit can be used.
    """
    return self.isActive and not self.is_expired()

Behavior: Credit is usable only if active AND not expired.


recalculate_balance() Method

Purpose: Recalculate remaining balance from relational StoreCreditTransaction records.

Source: models.py:763-788

Signature:

def recalculate_balance(self):

Implementation:

from django.db.models import Sum, Q

# Calculate total credits issued (positive)
total_issued = self.credit_transactions.filter(
    transactionType='issue'
).aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')

# Calculate total credits redeemed (negative impact on balance)
total_redeemed = self.credit_transactions.filter(
    transactionType='redeem'
).aggregate(Sum('amount'))['amount__sum'] or Decimal('0.00')

# Calculate new balance: initial + issued - redeemed
new_balance = self.initialAmount + total_issued - total_redeemed

# Ensure balance cannot go negative
self.remainingBalance = max(Decimal('0.00'), new_balance)

# Deactivate if balance reaches zero
if self.remainingBalance <= 0:
    self.isActive = False

Behavior:

  • Replaces JSON-based ledger with relational calculation
  • Auto-deactivates when balance reaches zero
  • Called automatically by StoreCreditTransaction.save()

CashDrawerEvent

Purpose: Logs events related to a physical cash drawer.

Source: models.py:796-849

Inheritance: SanityIntegratedModel, AuditableMixin

Class Definition:

class CashDrawerEvent(SanityIntegratedModel, AuditableMixin):
    """
    Logs events related to a physical cash drawer.
    """

Event Type Choices

Source: models.py:800-809

class EventType(models.TextChoices):
    OPEN = 'open', 'Drawer Opened'
    CLOSE = 'close', 'Drawer Closed'
    SALE = 'sale', 'Sale Transaction'
    RETURN = 'return', 'Return/Refund'
    CASH_IN = 'cash_in', 'Cash Added (Pay In)'
    CASH_OUT = 'cash_out', 'Cash Removed (Pay Out)'
    DEPOSIT = 'deposit', 'Cash Deposit'
    TILL_COUNT = 'till_count', 'Till Count'
    NO_SALE = 'no_sale', 'No Sale (Drawer Open)'

Usage: Tracks 9 different cash drawer events.


Fields

FieldTypeConstraintsDescription
eventIdCharField(100)unique, editable=FalseAuto-generated event ID
eventTypeCharField(20)choices=EventTypeType of event
storeForeignKey(Store)PROTECTStore where event occurred
employeeForeignKey(User)SET_NULL, null, blankEmployee who triggered event
registerIdCharField(50)-Physical POS register identifier
shiftIdCharField(100)blank, indexedEmployee shift identifier
amountDecimalField(10,2)default=0Cash amount involved
drawerTotalBeforeDecimalField(10,2)-Drawer total before event
drawerTotalAfterDecimalField(10,2)-Drawer total after event
transactionForeignKey(SaleTransaction)SET_NULL, null, blankAssociated transaction
cashBreakdownJSONFielddefault=dict, blankBreakdown of cash denominations
reasonCharField(255)blankReason for event
notesTextFieldblankAdditional notes
timestampDateTimeFieldauto_now_add, editable=FalseWhen event occurred

Source: models.py:811-831


save() Override

Source: models.py:843-846

def save(self, *args, **kwargs):
    if not self.eventId:
        self.eventId = f"CDE-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
    super().save(*args, **kwargs)

Behavior: Auto-generates eventId in format CDE-{YYYYMMDD}-{8-char-hex}.


Cash Breakdown Structure

JSONField Format:

cashBreakdown = {
    'hundreds': 5,    # 5 x $100 bills
    'fifties': 3,     # 3 x $50 bills
    'twenties': 10,   # 10 x $20 bills
    'tens': 15,       # 15 x $10 bills
    'fives': 20,      # 20 x $5 bills
    'ones': 50,       # 50 x $1 bills
    'quarters': 40,   # 40 quarters
    'dimes': 50,      # 50 dimes
    'nickels': 20,    # 20 nickels
    'pennies': 100    # 100 pennies
}

SaleLineItem

Purpose: Individual line item within a sales transaction with immutable snapshots.

Source: models.py:862-1006

Inheritance: SanityIntegratedModel, AuditableMixin

Class Definition:

class SaleLineItem(SanityIntegratedModel, AuditableMixin):
    """
    Individual line item within a sales transaction.

    This model replaces the JSON field storage with proper relational architecture.
    All fields are marked as read-only to create immutable historical records
    that capture point-in-time data for audit trails.
    """

Architecture: Moved from JSONField to relational model for audit integrity and query efficiency.


Fields

FieldTypeConstraintsEditableDescription
transactionForeignKey(SaleTransaction)CASCADEYesParent sales transaction
productVariantForeignKey(ProductVariant)PROTECTYesProduct variant reference
skuSnapshotTextField-NoSKU at time of sale (immutable)
nameSnapshotTextField-NoProduct name at time of sale (immutable)
quantityPositiveIntegerFielddefault=1YesQuantity purchased
unitPriceDecimalField(10,2)-YesPrice per unit at time of sale
totalPriceDecimalField(10,2)null, blankYesTotal price (auto-calculated)
lineDiscountDecimalField(10,2)default=0.00YesDiscount applied to line
promotionDiscountsJSONFielddefault=list, blankNoPromotional discount breakdown
isTaxExemptBooleanFielddefault=FalseNoExempt this line item from tax calculation
costAtSaleDecimalField(10,2)null, blankYesCost per unit from InventoryLevel.current_cost captured at checkout. null when no cost was recorded on the inventory level at sale time. Enables margin analytics against the cost current at time of sale. Added v0.73.0.

Source: models.py:871-928

Immutable Snapshots: skuSnapshot and nameSnapshot preserve historical product data even if product changes later.


Meta Configuration

Source: models.py:930-945

class Meta:
    ordering = ['id']  # Preserve insertion order
    indexes = [
        models.Index(fields=['transaction', 'id']),
        models.Index(fields=['productVariant', 'transaction']),
        models.Index(fields=['skuSnapshot']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
    ]
    constraints = [
        models.CheckConstraint(check=models.Q(quantity__gt=0), name='positive_quantity'),
        models.CheckConstraint(check=models.Q(unitPrice__gte=0), name='non_negative_unit_price'),
        models.CheckConstraint(check=models.Q(totalPrice__gte=0), name='non_negative_total_price'),
        models.CheckConstraint(check=models.Q(lineDiscount__gte=0), name='non_negative_line_discount'),
    ]

Database Constraints: 4 check constraints enforce business rules at database level.


Sync Readiness Properties

is_sync_ready Property

Source: models.py:950-961

@property
def is_sync_ready(self):
    """Check if line item has all data needed for sync"""
    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
    ])

Behavior: Validates all required fields are populated.


can_sync_to_sanity() Method

Source: models.py:963-968

def can_sync_to_sanity(self):
    """Check if ready AND parent is synced"""
    return (
        self.is_sync_ready and
        self.transaction.sanity_id is not None
    )

Behavior: Ensures parent transaction is synced before syncing line item.


save() Override

Purpose: Auto-populate immutable snapshot fields from current product data.

Source: models.py:970-985

Signature:

def save(self, *args, **kwargs):

Implementation:

if self._state.adding and self.productVariant:  # Only on creation
    # Capture immutable snapshots from current product state
    self.skuSnapshot = self.productVariant.sku or 'UNKNOWN-SKU'
    self.nameSnapshot = getattr(self.productVariant, 'name', '') or str(self.productVariant)

    # Auto-calculate totalPrice if not set
    if self.totalPrice is None:
        calculated_total = (self.unitPrice * self.quantity) - self.lineDiscount
        self.totalPrice = calculated_total

super().save(*args, **kwargs)

Behavior:

  • Only runs on creation (not updates)
  • Captures product SKU and name at sale time
  • Auto-calculates totalPrice if not provided
  • Ensures historical accuracy

clean() Validation

Source: models.py:987-1005

def clean(self):
    """Custom model validation for business rules."""
    super().clean()

    if self.quantity <= 0:
        raise ValidationError("Quantity must be positive.")

    if self.unitPrice < 0:
        raise ValidationError("Unit price cannot be negative.")

    if self.lineDiscount < 0:
        raise ValidationError("Line discount cannot be negative.")

    # Validate total price calculation
    expected_total = (self.unitPrice * self.quantity) - self.lineDiscount
    if abs(self.totalPrice - expected_total) > Decimal('0.01'):  # Allow for rounding
        raise ValidationError(
            f"Total price \${self.totalPrice} does not match calculated total \${expected_total}"
        )

Validation Rules:

  • Quantity must be positive
  • Unit price cannot be negative
  • Line discount cannot be negative
  • Total price must match calculation (within 1 cent for rounding)

Promotion Discounts Structure

JSONField Format:

promotionDiscounts = [
    {
        'promo_code': 'SPRING20',
        'campaign_name': 'Spring Sale 2025',
        'discount_amount': '5.99',
        'discount_type': 'percentage',
        'applied_at': '2025-11-17T10:30:00Z'
    },
    {
        'promo_code': None,
        'campaign_name': 'Black Friday',
        'discount_amount': '10.00',
        'discount_type': 'fixed',
        'applied_at': '2025-11-17T10:30:00Z'
    }
]

Purpose: Audit trail for promotional discounts applied to this line item.


StoreCreditTransaction

Purpose: Represents individual transactions in store credit ledger system.

Source: models.py:1008-1106

Inheritance: SanityIntegratedModel, AuditableMixin

Class Definition:

class StoreCreditTransaction(SanityIntegratedModel, AuditableMixin):
    """
    Represents individual transactions in store credit ledger system.
    Replaces JSON-based ledger with proper relational model for audit integrity.
    Mirrors Sanity storeCreditTransaction.js schema exactly.
    """

Architecture: Replaces JSON-based ledger for better audit integrity and query efficiency.


Transaction Type Choices

Source: models.py:1015-1017

class TransactionType(models.TextChoices):
    ISSUE = 'issue', 'Issue'
    REDEEM = 'redeem', 'Redeem'

Usage:

  • ISSUE: Credit added to account (from returns, adjustments)
  • REDEEM: Credit used in a purchase

Fields

FieldTypeConstraintsEditableDescription
store_creditForeignKey(StoreCredit)CASCADEYesStore credit account
transactionTypeCharField(10)choicesNoType of transaction (immutable)
amountDecimalField(10,2)-NoAmount (always positive, immutable)
salesTransactionForeignKey(SaleTransaction)PROTECT, null, blankYesSales transaction (for redemptions)
salesReturnForeignKey(Return)PROTECT, null, blankYesSales return (for issuance)
notesTextFieldblankNoOptional notes
timestampDateTimeFieldauto_now_addNoWhen transaction occurred (immutable)

Source: models.py:1019-1062

Important: All fields except relationships are editable=False for immutability.


Meta Configuration

Source: models.py:1064-1077

class Meta:
    ordering = ['-timestamp']  # Most recent first
    indexes = [
        models.Index(fields=['store_credit', '-timestamp']),
        models.Index(fields=['transactionType', 'store_credit']),
        models.Index(fields=['salesTransaction']),
        models.Index(fields=['salesReturn']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
    ]
    constraints = [
        models.CheckConstraint(check=models.Q(amount__gt=0), name='positive_credit_transaction_amount'),
    ]

Database Constraint: Amount must be positive (even for redemptions).


clean() Validation

Source: models.py:1082-1094

def clean(self):
    """Custom model validation for business rules."""
    super().clean()

    if self.amount <= 0:
        raise ValidationError("Transaction amount must be positive.")

    # Validate transaction type requirements
    if self.transactionType == 'redeem' and not self.salesTransaction:
        raise ValidationError("Redeem transactions must reference a sales transaction.")

    if self.transactionType == 'issue' and not self.salesReturn and not self.notes:
        raise ValidationError("Issue transactions must reference a return or include notes explaining the issuance.")

Validation Rules:

  • Amount must be positive
  • REDEEM transactions must reference a sales transaction
  • ISSUE transactions must reference a return OR include explanatory notes

save() Override

Purpose: Enforce immutability and trigger balance recalculation.

Source: models.py:1096-1106

Implementation:

def save(self, *args, **kwargs):
    """Override save to maintain immutable fields."""
    if self.pk:
        # Prevent modification of existing records
        raise ValidationError("StoreCreditTransaction records are immutable and cannot be modified.")

    super().save(*args, **kwargs)

    # Trigger store credit balance recalculation
    self.store_credit.recalculate_balance()
    self.store_credit.save()

Behavior:

  • Prevents updates to existing records (immutable)
  • Automatically recalculates parent StoreCredit balance on creation
  • Ensures audit integrity

ReturnLineItem

Purpose: Represents individual items in a sales return with immutable snapshots.

Source: models.py:1109-1237

Inheritance: SanityIntegratedModel, AuditableMixin

Class Definition:

class ReturnLineItem(SanityIntegratedModel, AuditableMixin):
    """
    Represents individual items in a sales return.
    Mirrors Sanity returnLineItem.js schema exactly with immutable snapshot fields.
    Functions as the return equivalent to SaleLineItem.
    """

Architecture: Relational model replacing JSONField, mirrors SaleLineItem pattern.

Alias: SalesReturnItem = ReturnLineItem for backward compatibility.


Return Reason Choices

Source: models.py:1116-1122

class ReturnReason(models.TextChoices):
    DAMAGED = 'damaged', 'Damaged'
    WRONG_SIZE = 'wrong_size', 'Wrong Size'
    UNWANTED_GIFT = 'unwanted_gift', 'Unwanted Gift'
    DEFECTIVE = 'defective', 'Defective'
    NOT_AS_DESCRIBED = 'not_as_described', 'Not as Described'
    CHANGED_MIND = 'changed_mind', 'Changed Mind'

Usage: Tracks 6 common return reasons.


Disposition Choices

Source: models.py:1124-1129

class Disposition(models.TextChoices):
    SELLABLE = 'sellable', 'Sellable'
    DEFECTIVE = 'defective', 'Defective'
    QUARANTINE = 'quarantine', 'Quarantine'
    DAMAGED_OUT = 'damaged_out', 'Damaged Out'
    VENDOR_RETURN = 'vendor_return', 'Vendor Return'

Usage: Determines what happens to returned item (impacts inventory).


Fields

FieldTypeConstraintsEditableDescription
sales_returnForeignKey(Return)CASCADEYesParent return record
original_sale_line_itemForeignKey(SaleLineItem)PROTECTYesOriginal line item being returned
reference_inventory_levelsManyToManyField(InventoryLevel)blankYesAssociated inventory levels
nameSnapshotTextField-NoProduct name at return time (immutable)
skuSnapshotTextField-NoSKU at return time (immutable)
quantityPositiveIntegerField-NoQuantity being returned (immutable)
returnPriceDecimalField(10,2)-NoValue of item (immutable)
reasonCharField(20)choices=ReturnReasonNoReturn reason (immutable)
dispositionCharField(20)choices=DispositionNoItem disposition (immutable)

Source: models.py:1131-1180

Immutable Snapshots: Most fields are editable=False to preserve audit trail.


Meta Configuration

Source: models.py:1182-1198

class Meta:
    db_table = 'returnlineitem'
    ordering = ['id']  # Preserve insertion order
    indexes = [
        models.Index(fields=['sales_return', 'id']),
        models.Index(fields=['original_sale_line_item']),
        models.Index(fields=['skuSnapshot']),
        models.Index(fields=['reason']),
        models.Index(fields=['disposition']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['sync_enabled', 'sanity_id']),
    ]
    constraints = [
        models.CheckConstraint(check=models.Q(quantity__gt=0), name='positive_return_line_item_quantity'),
        models.CheckConstraint(check=models.Q(returnPrice__gte=0), name='non_negative_return_line_item_price'),
    ]

Database Constraints: 2 check constraints for quantity and price.


save() Override

Purpose: Auto-populate immutable snapshot fields from original sale line item.

Source: models.py:1203-1217

Implementation:

def save(self, *args, **kwargs):
    """
    Auto-populate immutable snapshot fields from original sale line item.
    This ensures historical accuracy even if product details change later.
    """
    if not self.pk and self.original_sale_line_item:  # Only on creation
        # Capture immutable snapshots from original sale line item
        self.nameSnapshot = self.original_sale_line_item.nameSnapshot
        self.skuSnapshot = self.original_sale_line_item.skuSnapshot

        # For return price, use the unit price from original transaction
        # (could also be calculated differently based on business rules)
        self.returnPrice = self.original_sale_line_item.unitPrice * self.quantity

    super().save(*args, **kwargs)

Behavior:

  • Only runs on creation
  • Copies snapshots from original sale line item
  • Auto-calculates returnPrice based on original unit price
  • Preserves historical accuracy

clean() Validation

Source: models.py:1219-1233

def clean(self):
    """Custom model validation for business rules."""
    super().clean()

    if self.quantity <= 0:
        raise ValidationError("Return quantity must be positive.")

    if self.returnPrice < 0:
        raise ValidationError("Return price cannot be negative.")

    # Validate that return quantity doesn't exceed original quantity
    if self.original_sale_line_item and self.quantity > self.original_sale_line_item.quantity:
        raise ValidationError(
            f"Return quantity ({self.quantity}) cannot exceed original quantity ({self.original_sale_line_item.quantity})"
        )

Validation Rules:

  • Quantity must be positive
  • Return price cannot be negative
  • Return quantity cannot exceed original sale quantity

Fulfillment

Purpose: Captures user-submitted checkout data at time of purchase, including addresses, payment snapshot, and delivery method. One Fulfillment per SaleTransaction.

Source: models.py:1277-1397

Inheritance: SanityIntegratedModel, AuditableMixin

Added: November 2025

Class Definition:

class Fulfillment(SanityIntegratedModel, AuditableMixin):
    """
    Captures user-submitted checkout data at time of purchase.
    One Fulfillment per SaleTransaction.
    May contain multiple Shipments for split fulfillment.

    This is a snapshot of the checkout form data - address, payment, delivery method.
    Syncs to Sanity checkoutForm document.
    """

Fulfillment Status Choices

class FulfillmentStatus(models.TextChoices):
    PENDING = 'pending', 'Pending'
    PROCESSING = 'processing', 'Processing'
    SHIPPED = 'shipped', 'Shipped'
    DELIVERED = 'delivered', 'Delivered'
    CANCELLED = 'cancelled', 'Cancelled'

Fields

Customer Information:

FieldTypeDescription
customer_emailEmailFieldCustomer email at time of checkout

Shipping Address (structured for queryability):

FieldTypeDescription
shipping_first_nameCharField(100)First name
shipping_last_nameCharField(100)Last name
shipping_companyCharField(200)Company (optional)
shipping_addressCharField(255)Street address
shipping_apartmentCharField(100)Apartment/unit (optional)
shipping_cityCharField(100)City (indexed)
shipping_regionCharField(100)State/province
shipping_postal_codeCharField(20)ZIP/postal code
shipping_countryCharField(100)Country
shipping_phoneCharField(50)Phone number

Billing Address:

FieldTypeDescription
billing_same_as_shippingBooleanFieldTrue if same as shipping (default: True)
billing_first_nameCharField(100)First name (if different)
billing_last_nameCharField(100)Last name (if different)
billing_addressCharField(255)Street address (if different)
billing_apartmentCharField(100)Apartment (if different)
billing_cityCharField(100)City (if different)
billing_regionCharField(100)State/province (if different)
billing_postal_codeCharField(20)ZIP/postal code (if different)
billing_countryCharField(100)Country (if different)
billing_phoneCharField(50)Phone (if different)

Payment Details (masked for security):

FieldTypeDescription
payment_name_on_cardCharField(200)Name on card
payment_card_last_fourCharField(4)Last 4 digits only
payment_card_typeCharField(50)Card type (Visa, MC, etc.)

Delivery Method:

FieldTypeDescription
delivery_method_idCharField(100)Delivery method ID
delivery_method_titleCharField(200)Display title
delivery_method_turnaroundCharField(100)Turnaround time
delivery_method_price_displayCharField(50)Formatted price display
delivery_method_price_valueDecimalField(10,2)Numeric price value

Order Totals (snapshot):

FieldTypeDescription
total_item_countPositiveIntegerFieldNumber of items
total_subtotalDecimalField(10,2)Subtotal
total_shippingDecimalField(10,2)Shipping cost
total_taxDecimalField(10,2)Tax amount
total_amountDecimalField(10,2)Final total

Lifecycle:

FieldTypeDescription
statusCharField(20)Fulfillment status
created_atDateTimeFieldWhen created
updated_atDateTimeFieldWhen last updated

Meta Configuration

class Meta:
    db_table = 'fulfillment'
    ordering = ['-created_at']
    indexes = [
        models.Index(fields=['customer_email']),
        models.Index(fields=['shipping_city', 'shipping_region']),
        models.Index(fields=['status']),
        models.Index(fields=['created_at']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
    ]

Shipment

Purpose: Represents a physical shipment of line items. One Fulfillment can have multiple Shipments (split shipments). Each Shipment contains specific line items.

Source: models.py:1399-1479

Inheritance: SanityIntegratedModel, AuditableMixin

Added: November 2025

Class Definition:

class Shipment(SanityIntegratedModel, AuditableMixin):
    """
    Represents a physical shipment of line items.
    One Fulfillment can have multiple Shipments (split shipments).
    Each Shipment contains specific line items.

    This is the warehouse/logistics layer - tracking, carrier info, shipment status.
    """

Status Choices

status = models.CharField(
    max_length=50,
    choices=[
        ('pending', 'Pending'),
        ('picked', 'Picked'),
        ('packed', 'Packed'),
        ('shipped', 'Shipped'),
        ('in_transit', 'In Transit'),
        ('delivered', 'Delivered'),
        ('failed', 'Failed'),
    ],
    default='pending',
    db_index=True
)

Lifecycle: pendingpickedpackedshippedin_transitdelivered


Fields

FieldTypeConstraintsDescription
fulfillmentForeignKey(Fulfillment)CASCADEParent fulfillment record
line_itemsM2M(SaleLineItem)through=ShipmentLineItemLine items with quantities in this shipment
carrier_nameCharField(200)blankCarrier (UPS, FedEx, USPS, etc.)
tracking_numberCharField(200)blank, indexedCarrier tracking number
statusCharField(50)choices, indexedShipment status
shipped_atDateTimeFieldnull, blankWhen dispatched
delivered_atDateTimeFieldnull, blankWhen delivered
created_atDateTimeFieldauto_now_addWhen created
updated_atDateTimeFieldauto_nowWhen updated

Important: As of December 2025, the line_items M2M uses the ShipmentLineItem through model to enable partial quantity fulfillment.


Meta Configuration

class Meta:
    db_table = 'shipment'
    ordering = ['-created_at']
    indexes = [
        models.Index(fields=['fulfillment', '-created_at']),
        models.Index(fields=['status']),
        models.Index(fields=['tracking_number']),
        models.Index(fields=['shipped_at']),
        models.Index(fields=['delivered_at']),
        models.Index(fields=['sanity_id']),
        models.Index(fields=['last_synced_at']),
        models.Index(fields=['updated_at', 'last_synced_at']),
    ]

Relationship to SaleTransaction

Fulfillment is linked to SaleTransaction via a FK on SaleTransaction:

# On SaleTransaction model
fulfillment = models.ForeignKey(
    'Fulfillment',
    on_delete=models.SET_NULL,
    null=True,
    blank=True,
    related_name='transactions'
)

This allows:

  • In-store sales to have no fulfillment (fulfillment=None)
  • Web orders to have fulfillment with shipping details
  • Split shipments via multiple Shipment records under one Fulfillment

ShipmentLineItem

Purpose: Through model for Shipment-SaleLineItem relationship enabling partial quantity fulfillment per shipment.

Source: models.py:1538-1595

Added: December 2025

Class Definition:

class ShipmentLineItem(models.Model):
    """
    Through model for Shipment-SaleLineItem relationship.
    Enables partial quantity fulfillment by tracking quantity_shipped per shipment.
    Maps to Sanity's quantityFulfilled in transactionFulfillment objects.
    """

Business Rules

  • Sum of quantity_shipped across all shipments for a line item must not exceed line_item.quantity
  • Multiple shipments can contain the same line item with different quantities
  • Supports split fulfillment scenarios (e.g., warehouse A ships 3, warehouse B ships 2)

Fields

FieldTypeConstraintsDescription
shipmentForeignKey(Shipment)CASCADEParent shipment
line_itemForeignKey(SaleLineItem)CASCADELine item being fulfilled
quantity_shippedPositiveIntegerFieldgt=0Quantity in this shipment
created_atDateTimeFieldauto_now_addWhen created
updated_atDateTimeFieldauto_nowWhen updated

Meta Configuration

class Meta:
    db_table = 'shipment_line_item'
    unique_together = [['shipment', 'line_item']]  # One entry per shipment-lineitem pair
    ordering = ['-created_at']
    verbose_name = 'Shipment Line Item'
    verbose_name_plural = 'Shipment Line Items'
    indexes = [
        models.Index(fields=['shipment', 'line_item']),
        models.Index(fields=['line_item']),
    ]
    constraints = [
        models.CheckConstraint(
            check=models.Q(quantity_shipped__gt=0),
            name='shipment_line_item_positive_quantity'
        ),
    ]

Use Cases

Partial Fulfillment Example:

# Order has 5 widgets (line_item.quantity = 5)
# Warehouse A ships 3
ShipmentLineItem.objects.create(
    shipment=shipment_a,
    line_item=line_item,
    quantity_shipped=3
)

# Warehouse B ships remaining 2
ShipmentLineItem.objects.create(
    shipment=shipment_b,
    line_item=line_item,
    quantity_shipped=2
)

Querying Fulfillment Status:

# Get total shipped quantity for a line item
from django.db.models import Sum

total_shipped = ShipmentLineItem.objects.filter(
    line_item=line_item
).aggregate(total=Sum('quantity_shipped'))['total'] or 0

remaining = line_item.quantity - total_shipped

Field Reference Tables

SaleTransaction Fields Summary

FieldTypeIndexedDefaultNullDescription
transactionIdCharField(100)YesAutoNoUnique transaction ID
storeFK(Store)Yes-NoStore reference
employeeFK(User)No-YesEmployee reference
customerFK(Customer)Yes-YesCustomer reference
subtotalDecimal(10,2)No0.00NoSubtotal before discounts
taxAmountDecimal(10,2)No0.00NoTax applied
totalAmountDecimal(10,2)No0.00NoFinal total
orderLevelDiscountDecimal(10,2)No0.00NoPromotional discount
fulfillmentMethodCharField(20)YesCOUNTER_SALENoFulfillment type
statusCharField(20)YesPENDINGNoTransaction status
timestampDateTimeYesAutoNoCreation time
guest_access_tokenCharField(64)Yes-YesSecure token for guest order tracking (web commerce)

guest_access_token (Added December 2025): A unique 64-character secure token that enables guest users (not logged in) to track their web orders. Generated during checkout and used to authenticate order status requests without requiring customer login.


Return Fields Summary

FieldTypeIndexedDefaultNullDescription
returnIdCharField(100)YesAutoNoUnique return ID
originalSaleIdFK(SaleTransaction)No-NoOriginal sale
statusCharField(20)YesPENDINGNoReturn status
employeeIdFK(User)No-YesInitiating employee
processedByFK(User)No-YesProcessing employee
timestampDateTimeNoAutoNoCreation time
processedAtDateTimeNo-YesProcessing time

StoreCredit Fields Summary

FieldTypeIndexedDefaultNullDescription
creditIdCharField(100)YesAutoNoUnique credit ID
customerIdFK(Customer)No-NoCustomer owner
initialAmountDecimal(10,2)No-NoOriginal amount
remainingBalanceDecimal(10,2)No-NoCurrent balance
expirationDateDateFieldYes+365 daysYesExpiration date
isActiveBooleanNoTrueNoUsability flag

Was this page helpful?