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
- Overview
- Custom Managers
- Security & Audit Models
- Shopping Cart Models
- Core Transaction Models
- 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 returnedemployee(User): Employee processing the returnreason(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
| Field | Type | Constraints | Description |
|---|---|---|---|
audit_id | UUIDField | unique, auto | Unique audit log entry ID |
transaction_id | CharField(100) | indexed | Transaction identifier |
action | CharField(50) | choices=Action | Type of action performed |
actor_type | CharField(20) | - | Actor category ('employee', 'customer', 'system') |
actor_id | PositiveIntegerField | - | ID of actor |
actor_name | CharField(255) | - | Name of actor for readability |
data_before | JSONField | default=dict | Snapshot before action |
data_after | JSONField | default=dict | Snapshot after action |
timestamp | DateTimeField | auto_now_add | When action occurred |
ip_address | GenericIPAddressField | - | IP address of request |
user_agent | TextField | blank | Browser/client user agent |
session_id | CharField(40) | blank | Session identifier |
reason | TextField | blank | Explanation for action |
metadata | JSONField | default=dict | Additional 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
| Field | Type | Constraints | Description |
|---|---|---|---|
attempt_id | UUIDField | unique, auto | Unique attempt identifier |
payment | ForeignKey | CASCADE | Reference to TransactionPayment |
attempt_number | PositiveIntegerField | unique_together | Attempt sequence number |
status | CharField(20) | choices=Status, default=INITIATED | Current attempt status |
attempted_amount | DecimalField(10,2) | - | Amount attempted |
processor_reference | CharField(255) | blank | External processor reference |
initiated_at | DateTimeField | auto_now_add | When attempt started |
completed_at | DateTimeField | null, blank | When attempt finished |
expires_at | DateTimeField | auto-calculated | When attempt expires (15 min) |
error_code | CharField(50) | blank | Error code if failed |
error_message | TextField | blank | Error description |
processor_response | JSONField | default=dict | Full processor response |
ip_address | GenericIPAddressField | - | Request IP address |
fraud_score | DecimalField(5,2) | null, blank | Fraud detection score |
risk_factors | JSONField | default=list | Detected 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
| Field | Type | Constraints | Description |
|---|---|---|---|
idempotency_key | CharField(255) | unique, indexed | Client-provided idempotency key |
request_hash | CharField(64) | - | SHA256 hash of request body |
operation_type | CharField(50) | - | Type of operation |
resource_type | CharField(50) | - | Type of resource affected |
resource_id | CharField(100) | - | ID of affected resource |
response_status | PositiveIntegerField | - | HTTP status code returned |
response_data | JSONField | - | Cached response data |
created_at | DateTimeField | auto_now_add | When request first processed |
expires_at | DateTimeField | auto-calculated | When cache expires (1 hour) |
user_id | PositiveIntegerField | null, blank | User who made request |
ip_address | GenericIPAddressField | - | 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
| Field | Type | Constraints | Description |
|---|---|---|---|
payment | OneToOneField | CASCADE | Payment being analyzed |
risk_level | CharField(20) | choices=RiskLevel | Overall risk assessment |
risk_score | DecimalField(5,2) | - | Numeric risk score (0-100) |
status | CharField(20) | choices=Status, default=PENDING | Review status |
risk_factors | JSONField | default=list | Detected risk indicators |
detection_rules_triggered | JSONField | default=list | Rules that fired |
ml_model_results | JSONField | default=dict | ML model outputs |
device_fingerprint | CharField(64) | blank | Device identifier hash |
velocity_checks | JSONField | default=dict | Velocity analysis results |
geolocation_data | JSONField | default=dict | IP geolocation info |
analyzed_at | DateTimeField | auto_now_add | When analysis completed |
reviewed_at | DateTimeField | null, blank | When manually reviewed |
reviewed_by | ForeignKey(User) | SET_NULL | User who reviewed |
external_fraud_checks | JSONField | default=dict | Third-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
| Field | Type | Constraints | Description |
|---|---|---|---|
transaction_id | CharField(100) | unique | Transaction being locked |
locked_by | ForeignKey(User) | CASCADE | User holding the lock |
locked_at | DateTimeField | auto_now_add | When lock acquired |
expires_at | DateTimeField | auto-calculated | When lock expires (5 min) |
operation | CharField(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 usedMERGED: Guest cart merged into customer cart on loginCOMPLETED: Converted to SaleTransaction
Fields
| Field | Type | Constraints | Description |
|---|---|---|---|
cartId | UUIDField | unique, indexed, db_column='cart_id' | Auto-generated cart identifier |
customer | ForeignKey(Customer) | CASCADE, null, blank | Cart owner (null for guest) |
status | CharField(20) | choices=Status, default=ACTIVE, indexed | Cart state |
line_items | JSONField | default=list, blank | Array 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'
}
]
Using JSONField for cart (temporary data) vs relational SaleLineItem for transactions (permanent audit trail).
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: PENDING → PARTIALLY_PAID → COMPLETED → REFUNDED/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.
| Status | Description |
|---|---|
PENDING | Transaction created; no payment applied yet |
PARTIALLY_PAID | Intermediate 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). |
COMPLETED | Fully paid. Set automatically when payment_balance reaches zero. |
VOIDED | Cancelled by staff (manager override may be required). Distinct from customer cancellation. |
REFUNDED | Fully refunded after completion. |
PARTIALLY_REFUNDED | One or more line items refunded; remainder still settled. |
CANCELLED | Customer-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_ACTION | Card 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, andPAYMENT_REQUIRES_ACTION. Querying only forpendingorcompletedwill 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
| Field | Type | Constraints | DB Column | Description |
|---|---|---|---|---|
transactionId | CharField(100) | unique, indexed | transaction_id | Auto-generated transaction ID |
store | ForeignKey(Store) | PROTECT | - | Store where sale occurred |
employee | ForeignKey(User) | SET_NULL, null, blank | - | Employee who processed sale |
customer | ForeignKey(Customer) | SET_NULL, null, blank | - | Customer who made purchase |
subtotal | DecimalField(10,2) | default=0.00 | - | Total before tax and discounts |
taxAmount | DecimalField(10,2) | default=0.00 | tax_amount | Tax applied |
tax_breakdown | JSONField | default=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. |
appliedPromotionDiscount | DecimalField(10,2) | default=0.00 | - | Total automatic promotion discount applied at checkout. Separate from orderLevelDiscount which is promo-code-only. |
totalAmount | DecimalField(10,2) | default=0.00 | total_amount | Final total after tax |
orderLevelDiscount | DecimalField(10,2) | default=0.00 | - | Promotional discount applied |
appliedPromoCodes | ManyToManyField | blank | - | Promo codes used |
sourceCheckoutId | TextField | blank | - | Originating cart/checkout ID |
fulfillment | OneToOneField(Fulfillment) | SET_NULL, null, blank | - | Fulfillment record holding the checkout form data snapshot (shipping/billing address, delivery method). |
fulfillmentMethod | CharField(20) | choices, default=COUNTER_SALE, indexed | fulfillment_method | How order fulfilled |
payment_timing | CharField(20) | choices, default=IMMEDIATE, indexed | - | When payment is collected. Choices: immediate ("Pay Now"), at_pickup ("Pay at Store Pickup"). |
source_system | CharField(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. |
status | CharField(30) | choices, default=PENDING, indexed | - | Transaction status |
timestamp | DateTimeField | auto_now_add, editable=False | - | When transaction created |
completedAt | DateTimeField | null, blank | completed_at | When transaction completed |
voidedAt | DateTimeField | null, blank | voided_at | When transaction voided |
voidReason | TextField | blank | void_reason | Why transaction voided |
ab_test_variant | CharField(1) | null, blank | ab_test_variant | A/B test bucket assigned at checkout (a or b) |
guest_access_token | CharField(64) | unique, indexed, null, blank | - | Secure token for guest order tracking (web commerce only) |
cancelledAt | DateTimeField | null, blank | cancelled_at | When transaction was cancelled |
cancellationReason | TextField | blank | cancellation_reason | Why transaction was cancelled |
cancelledBy | ForeignKey(User) | SET_NULL, null, blank | - | User who cancelled (customer or staff) |
expires_at | DateTimeField | null, blank | expires_at | Expiry datetime for layaway and pay-at-store transactions. Populated by CartService or POS on creation. null for counter-sale transactions. |
notes | TextField | blank=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:
-
Calculate base subtotal from line items, falling back to the current
subtotalwhen there are none yet. -
Integrate with
PromotionService.evaluate_transaction()to setorderLevelDiscount. On success this also syncs per-line-item promotional discount breakdowns via_apply_promotion_discounts_to_line_items(). OnImportErroror any other exception it logs and falls back to zero discount rather than breaking the transaction. -
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 is0.00with no warning. If the store has no jurisdiction configured at all,DEFAULT_TAX_RATE_FALLBACK(from settings) is used and a warning is logged. -
Compute the taxable subtotal by excluding line items where
isTaxExempt=True, then apply the order-level discount proportionally to that taxable portion before computingbase_tax. -
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.
-
Build
tax_breakdownvia_build_tax_breakdown(base_tax, excise_components): see below. -
Set
totalAmountto the discounted subtotal plustaxAmount, and save the affected fields (subtotal,taxAmount,totalAmount,orderLevelDiscount,tax_breakdown) whencommit=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: PENDING → PROCESSED or CANCELLED
Fields
| Field | Type | Constraints | Description |
|---|---|---|---|
returnId | CharField(100) | unique, indexed | Auto-generated return ID |
originalSaleId | ForeignKey(SaleTransaction) | PROTECT | Original sale being returned |
reason | TextField | blank | Reason for return |
status | CharField(20) | choices, default=PENDING, indexed | Return status |
employeeId | ForeignKey(User) | SET_NULL, null, blank | Employee who initiated return |
processedBy | ForeignKey(User) | SET_NULL, null, blank | Employee who processed return |
timestamp | DateTimeField | auto_now_add, editable=False | When return created |
processedAt | DateTimeField | null, blank | When return processed |
Source: models.py:610-649
Removed Fields (as of November 2025):
itemsJSONField - Now using relational ReturnLineItem model (accessed viareturn_line_itemsrelated manager)refund_amountDecimalField - Removed (refunds now tracked via Refund.sales_return FK in payments app)store_credit_issuedDecimalField - Removed (store credits now linked via reference arrays)
Important Architectural Changes:
-
Relational Line Items: Return items are NO LONGER stored in JSONField - now relational via
return_line_itemsreverse relationship. -
Refund-First Architecture: Refunds are processed BEFORE the Return record is created, then linked afterward:
- Refunds created with
sales_return=Noneinitially - Return record created after refunds processed
- Refunds updated to link to Return via
Refund.sales_returnFK - See services.md for implementation details
- Refunds created with
-
No Financial Amount Fields: Return model no longer stores
refund_amountorstore_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
| Field | Type | Constraints | Description |
|---|---|---|---|
creditId | CharField(100) | unique, indexed | Auto-generated credit ID |
customerId | ForeignKey(Customer) | CASCADE | Customer who owns credit |
initialAmount | DecimalField(10,2) | - | Original credit amount |
issuedDate | DateField | null, blank | When credit issued |
expirationDate | DateField | null, blank, indexed | When credit expires |
isActive | BooleanField | default=True | Whether credit can be used |
remainingBalance | DecimalField(10,2) | editable=False | Current balance (computed) |
sourceReturn | ForeignKey(Return) | SET_NULL, null, blank | Return 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:
initialAmountmust be positiveremainingBalancemust be non-negativeremainingBalancecannot exceedinitialAmount
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
| Field | Type | Constraints | Description |
|---|---|---|---|
eventId | CharField(100) | unique, editable=False | Auto-generated event ID |
eventType | CharField(20) | choices=EventType | Type of event |
store | ForeignKey(Store) | PROTECT | Store where event occurred |
employee | ForeignKey(User) | SET_NULL, null, blank | Employee who triggered event |
registerId | CharField(50) | - | Physical POS register identifier |
shiftId | CharField(100) | blank, indexed | Employee shift identifier |
amount | DecimalField(10,2) | default=0 | Cash amount involved |
drawerTotalBefore | DecimalField(10,2) | - | Drawer total before event |
drawerTotalAfter | DecimalField(10,2) | - | Drawer total after event |
transaction | ForeignKey(SaleTransaction) | SET_NULL, null, blank | Associated transaction |
cashBreakdown | JSONField | default=dict, blank | Breakdown of cash denominations |
reason | CharField(255) | blank | Reason for event |
notes | TextField | blank | Additional notes |
timestamp | DateTimeField | auto_now_add, editable=False | When 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
| Field | Type | Constraints | Editable | Description |
|---|---|---|---|---|
transaction | ForeignKey(SaleTransaction) | CASCADE | Yes | Parent sales transaction |
productVariant | ForeignKey(ProductVariant) | PROTECT | Yes | Product variant reference |
skuSnapshot | TextField | - | No | SKU at time of sale (immutable) |
nameSnapshot | TextField | - | No | Product name at time of sale (immutable) |
quantity | PositiveIntegerField | default=1 | Yes | Quantity purchased |
unitPrice | DecimalField(10,2) | - | Yes | Price per unit at time of sale |
totalPrice | DecimalField(10,2) | null, blank | Yes | Total price (auto-calculated) |
lineDiscount | DecimalField(10,2) | default=0.00 | Yes | Discount applied to line |
promotionDiscounts | JSONField | default=list, blank | No | Promotional discount breakdown |
isTaxExempt | BooleanField | default=False | No | Exempt this line item from tax calculation |
costAtSale | DecimalField(10,2) | null, blank | Yes | Cost 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
| Field | Type | Constraints | Editable | Description |
|---|---|---|---|---|
store_credit | ForeignKey(StoreCredit) | CASCADE | Yes | Store credit account |
transactionType | CharField(10) | choices | No | Type of transaction (immutable) |
amount | DecimalField(10,2) | - | No | Amount (always positive, immutable) |
salesTransaction | ForeignKey(SaleTransaction) | PROTECT, null, blank | Yes | Sales transaction (for redemptions) |
salesReturn | ForeignKey(Return) | PROTECT, null, blank | Yes | Sales return (for issuance) |
notes | TextField | blank | No | Optional notes |
timestamp | DateTimeField | auto_now_add | No | When 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
| Field | Type | Constraints | Editable | Description |
|---|---|---|---|---|
sales_return | ForeignKey(Return) | CASCADE | Yes | Parent return record |
original_sale_line_item | ForeignKey(SaleLineItem) | PROTECT | Yes | Original line item being returned |
reference_inventory_levels | ManyToManyField(InventoryLevel) | blank | Yes | Associated inventory levels |
nameSnapshot | TextField | - | No | Product name at return time (immutable) |
skuSnapshot | TextField | - | No | SKU at return time (immutable) |
quantity | PositiveIntegerField | - | No | Quantity being returned (immutable) |
returnPrice | DecimalField(10,2) | - | No | Value of item (immutable) |
reason | CharField(20) | choices=ReturnReason | No | Return reason (immutable) |
disposition | CharField(20) | choices=Disposition | No | Item 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:
| Field | Type | Description |
|---|---|---|
customer_email | EmailField | Customer email at time of checkout |
Shipping Address (structured for queryability):
| Field | Type | Description |
|---|---|---|
shipping_first_name | CharField(100) | First name |
shipping_last_name | CharField(100) | Last name |
shipping_company | CharField(200) | Company (optional) |
shipping_address | CharField(255) | Street address |
shipping_apartment | CharField(100) | Apartment/unit (optional) |
shipping_city | CharField(100) | City (indexed) |
shipping_region | CharField(100) | State/province |
shipping_postal_code | CharField(20) | ZIP/postal code |
shipping_country | CharField(100) | Country |
shipping_phone | CharField(50) | Phone number |
Billing Address:
| Field | Type | Description |
|---|---|---|
billing_same_as_shipping | BooleanField | True if same as shipping (default: True) |
billing_first_name | CharField(100) | First name (if different) |
billing_last_name | CharField(100) | Last name (if different) |
billing_address | CharField(255) | Street address (if different) |
billing_apartment | CharField(100) | Apartment (if different) |
billing_city | CharField(100) | City (if different) |
billing_region | CharField(100) | State/province (if different) |
billing_postal_code | CharField(20) | ZIP/postal code (if different) |
billing_country | CharField(100) | Country (if different) |
billing_phone | CharField(50) | Phone (if different) |
Payment Details (masked for security):
| Field | Type | Description |
|---|---|---|
payment_name_on_card | CharField(200) | Name on card |
payment_card_last_four | CharField(4) | Last 4 digits only |
payment_card_type | CharField(50) | Card type (Visa, MC, etc.) |
Delivery Method:
| Field | Type | Description |
|---|---|---|
delivery_method_id | CharField(100) | Delivery method ID |
delivery_method_title | CharField(200) | Display title |
delivery_method_turnaround | CharField(100) | Turnaround time |
delivery_method_price_display | CharField(50) | Formatted price display |
delivery_method_price_value | DecimalField(10,2) | Numeric price value |
Order Totals (snapshot):
| Field | Type | Description |
|---|---|---|
total_item_count | PositiveIntegerField | Number of items |
total_subtotal | DecimalField(10,2) | Subtotal |
total_shipping | DecimalField(10,2) | Shipping cost |
total_tax | DecimalField(10,2) | Tax amount |
total_amount | DecimalField(10,2) | Final total |
Lifecycle:
| Field | Type | Description |
|---|---|---|
status | CharField(20) | Fulfillment status |
created_at | DateTimeField | When created |
updated_at | DateTimeField | When 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: pending → picked → packed → shipped → in_transit → delivered
Fields
| Field | Type | Constraints | Description |
|---|---|---|---|
fulfillment | ForeignKey(Fulfillment) | CASCADE | Parent fulfillment record |
line_items | M2M(SaleLineItem) | through=ShipmentLineItem | Line items with quantities in this shipment |
carrier_name | CharField(200) | blank | Carrier (UPS, FedEx, USPS, etc.) |
tracking_number | CharField(200) | blank, indexed | Carrier tracking number |
status | CharField(50) | choices, indexed | Shipment status |
shipped_at | DateTimeField | null, blank | When dispatched |
delivered_at | DateTimeField | null, blank | When delivered |
created_at | DateTimeField | auto_now_add | When created |
updated_at | DateTimeField | auto_now | When 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_shippedacross all shipments for a line item must not exceedline_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
| Field | Type | Constraints | Description |
|---|---|---|---|
shipment | ForeignKey(Shipment) | CASCADE | Parent shipment |
line_item | ForeignKey(SaleLineItem) | CASCADE | Line item being fulfilled |
quantity_shipped | PositiveIntegerField | gt=0 | Quantity in this shipment |
created_at | DateTimeField | auto_now_add | When created |
updated_at | DateTimeField | auto_now | When 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
| Field | Type | Indexed | Default | Null | Description |
|---|---|---|---|---|---|
| transactionId | CharField(100) | Yes | Auto | No | Unique transaction ID |
| store | FK(Store) | Yes | - | No | Store reference |
| employee | FK(User) | No | - | Yes | Employee reference |
| customer | FK(Customer) | Yes | - | Yes | Customer reference |
| subtotal | Decimal(10,2) | No | 0.00 | No | Subtotal before discounts |
| taxAmount | Decimal(10,2) | No | 0.00 | No | Tax applied |
| totalAmount | Decimal(10,2) | No | 0.00 | No | Final total |
| orderLevelDiscount | Decimal(10,2) | No | 0.00 | No | Promotional discount |
| fulfillmentMethod | CharField(20) | Yes | COUNTER_SALE | No | Fulfillment type |
| status | CharField(20) | Yes | PENDING | No | Transaction status |
| timestamp | DateTime | Yes | Auto | No | Creation time |
| guest_access_token | CharField(64) | Yes | - | Yes | Secure 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
| Field | Type | Indexed | Default | Null | Description |
|---|---|---|---|---|---|
| returnId | CharField(100) | Yes | Auto | No | Unique return ID |
| originalSaleId | FK(SaleTransaction) | No | - | No | Original sale |
| status | CharField(20) | Yes | PENDING | No | Return status |
| employeeId | FK(User) | No | - | Yes | Initiating employee |
| processedBy | FK(User) | No | - | Yes | Processing employee |
| timestamp | DateTime | No | Auto | No | Creation time |
| processedAt | DateTime | No | - | Yes | Processing time |
StoreCredit Fields Summary
| Field | Type | Indexed | Default | Null | Description |
|---|---|---|---|---|---|
| creditId | CharField(100) | Yes | Auto | No | Unique credit ID |
| customerId | FK(Customer) | No | - | No | Customer owner |
| initialAmount | Decimal(10,2) | No | - | No | Original amount |
| remainingBalance | Decimal(10,2) | No | - | No | Current balance |
| expirationDate | DateField | Yes | +365 days | Yes | Expiration date |
| isActive | Boolean | No | True | No | Usability flag |
Related Documentation
- Serializers - Transaction serialization
- Views - Transaction API endpoints
- Services - Transaction business logic
- Signals - Bidirectional sync with Sanity
- Products Models - ProductVariant relationships
- Stores Models - Store relationships
- Users Models - Employee and Customer relationships