Payments Domain - Models Documentation
Complete documentation of Payments domain data layer.
Source: api/nextango/apps/payments/models.py
Last Updated: 2026-07-08
Table of Contents
Overview
The Payments models define payment processing infrastructure for the multi-store retail system, with strong integration to Sanity CMS and external payment processors.
Key Models:
- PaymentProcessor (6 models total): Payment provider configuration (Stripe, Square, PayPal)
- PaymentMethod: Payment method definitions (cash, cards, digital wallets)
- TransactionPayment: Individual payment records for transactions
- Refund: Refund tracking and processing
- PaymentMethodStore: Store-specific payment method availability
- WebhookEvent: Webhook idempotency and audit tracking
Architectural Patterns:
- Sanity Integration: PaymentProcessor, PaymentMethod, TransactionPayment, Refund extend
SanityIntegratedModel - PCI Compliance: Data retention policies, anonymization tracking, secure credential handling
- Security First: Idempotency keys, fraud detection, 3D Secure, CVV/AVS verification
- Audit Trail: Full metadata, processor responses, webhook event tracking
- Denormalization: payment_method_type cached for performance
- Auto-generated IDs: REF-YYYYMMDD-XXXXXXXX format for refunds
Configuration Models
PaymentProcessor
Payment processing provider configuration and settings.
Source: models.py:10-86
Extends: SanityIntegratedModel, AuditableMixin
Purpose: Defines external payment processors (Stripe, Square, PayPal) with their configuration and fee structures.
Fields
class PaymentProcessor(SanityIntegratedModel, AuditableMixin):
name = models.CharField(max_length=255)
processor_type = models.CharField(max_length=50, choices=[...])
is_active = models.BooleanField(default=True)
is_test_mode = models.BooleanField(default=False)
configuration = models.JSONField(default=dict, blank=True)
processing_fee_percentage = models.DecimalField(max_digits=6, decimal_places=4, null=True, blank=True)
processing_fee_fixed = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
default_currency = models.CharField(max_length=3, default='USD')
name
- Type: CharField(255)
- Required: Yes
- Description: Display name of the processor
- Example:
"Stripe Production","Square Test","PayPal Sandbox"
processor_type
- Type: CharField(50)
- Choices:
'stripe': Stripe'square': Square'paypal': PayPal'custom': Custom processor'elavon': Elavon
- Required: Yes
- Description: Type of payment processor
- Example:
'stripe'
is_active
- Type: BooleanField
- Default:
True - Description: Whether this processor is currently active
- Usage: Filter payment methods to only show active processors
is_test_mode
- Type: BooleanField
- Default:
False - Description: Whether this processor is in test/sandbox mode
- Security: Test mode processors should NEVER be used in production transactions
configuration
- Type: JSONField
- Default:
{} - Security: NON-SENSITIVE settings only
- Allowed Values: webhook URLs, timeout values, display preferences
- FORBIDDEN Values: API keys, tokens, secrets, credentials
Critical Security Note:
help_text=(
'Payment Processor Configuration - NON-SENSITIVE settings only '
'(e.g., webhook URLs, timeout values, display preferences). '
'DO NOT store API keys, tokens, or secrets here. '
'Credentials are loaded from environment variables (.env) in development '
'and Docker secrets in production. See settings/base.py for credential configuration.'
)
Example Configuration:
{
"webhook_url": "https://api.example.com/webhooks/stripe",
"timeout_seconds": 30,
"retry_attempts": 3,
"display_name": "Credit Card Processing"
}
NEVER Store (use environment variables instead):
{
"api_key": "sk_live_...", // FORBIDDEN
"secret": "whsec_...", // FORBIDDEN
"access_token": "..." // FORBIDDEN
}
processing_fee_percentage
- Type: DecimalField(6, 4)
- Example:
0.0290(2.9%) - Nullable: Yes
- Description: Percentage-based processing fee charged by processor
- Calculation:
payment_amount * processing_fee_percentage
processing_fee_fixed
- Type: DecimalField(10, 2)
- Example:
0.30($0.30 fixed fee) - Nullable: Yes
- Description: Fixed processing fee per transaction
- Calculation: Combined with percentage:
(amount * percentage) + fixed
Example Fee Calculation (Stripe typical):
# Stripe charges 2.9% + $0.30
processing_fee_percentage = Decimal('0.0290')
processing_fee_fixed = Decimal('0.30')
# For a $100 payment:
fee = (Decimal('100.00') * processing_fee_percentage) + processing_fee_fixed
# fee = $2.90 + $0.30 = $3.20
default_currency
- Type: CharField(3)
- Default:
'USD' - Validation: Must be 3-letter ISO currency code
- Example:
'USD','EUR','GBP','CAD'
Validation:
def clean(self):
if self.default_currency and len(self.default_currency) != 3:
raise ValidationError('Default currency must be a 3-letter code')
Meta Configuration
class Meta:
db_table = 'payment_processor'
ordering = ['name']
indexes = [
models.Index(fields=['processor_type'], name='payment_processor_type_idx'),
models.Index(fields=['is_active'], name='payment_processor_active_idx'),
]
Indexes:
processor_type: Fast filtering by processor typeis_active: Quick queries for active processors only
Methods
__str__()
Source: models.py:76-79
def __str__(self):
status = 'Active' if self.is_active else 'Inactive'
mode = ' (Test)' if self.is_test_mode else ''
return f'{self.name} - {self.processor_type} - {status}{mode}'
Example Outputs:
"Stripe Production - stripe - Active""Square Test - square - Active (Test)""PayPal Sandbox - paypal - Inactive (Test)"
save()
Source: models.py:81-83
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
save() calls self.full_clean() before delegating to super().save(). This enforces model-level validation on every write path, including ORM calls that bypass form validation.
What this enforces on every save:
processor_typemust be one of the declared choices (stripe,square,paypal,custom,elavon)default_currencymust be exactly 3 characters (viaclean()below)
API impact: POST/PUT/PATCH to /payment-processors/ may now return HTTP 400 with a validation error where previously invalid data was silently saved. Callers must ensure processor_type is a valid choice and default_currency is a 3-letter ISO code.
clean()
Source: models.py:85-89
Validation:
- Calls parent
clean()(SanityIntegratedModel validation) - Validates currency code is exactly 3 characters
def clean(self):
super().clean()
if self.default_currency and len(self.default_currency) != 3:
raise ValidationError('Default currency must be a 3-letter code')
Usage Examples
Creating a Processor:
from nextango.apps.payments.models import PaymentProcessor
processor = PaymentProcessor.objects.create(
name='Stripe Production',
processor_type='stripe',
is_active=True,
is_test_mode=False,
processing_fee_percentage=Decimal('0.0290'),
processing_fee_fixed=Decimal('0.30'),
default_currency='USD',
configuration={
'webhook_url': 'https://api.example.com/webhooks/stripe',
'timeout_seconds': 30
}
)
Querying Active Processors:
# Get all active processors
active_processors = PaymentProcessor.objects.filter(is_active=True)
# Get production processors only
production = PaymentProcessor.objects.filter(
is_active=True,
is_test_mode=False
)
# Get Stripe processor
stripe = PaymentProcessor.objects.get(processor_type='stripe', is_active=True)
PaymentMethod
Payment method configuration defining available payment options.
Source: models.py:88-180
Extends: SanityIntegratedModel, AuditableMixin
Purpose: Defines available payment methods (cash, credit card, digital wallet) with their configuration, availability, and display settings.
Fields
class PaymentMethod(SanityIntegratedModel, AuditableMixin):
name = models.CharField(max_length=255)
method_type = models.CharField(max_length=50, choices=[...])
processor = models.ForeignKey(PaymentProcessor, on_delete=models.SET_NULL, null=True, blank=True)
is_active = models.BooleanField(default=True)
is_online_available = models.BooleanField(default=True)
is_pos_available = models.BooleanField(default=True)
minimum_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.01)
maximum_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
configuration = models.JSONField(default=dict, blank=True)
display_order = models.IntegerField(default=0)
icon_url = models.URLField(null=True, blank=True)
description = models.TextField(blank=True)
name
- Type: CharField(255)
- Required: Yes
- Description: Display name of the payment method
- Example:
"Cash","Visa/Mastercard","Apple Pay","Store Credit"
method_type
- Type: CharField(50)
- Choices:
'cash': Cash'credit_card': Credit Card'debit_card': Debit Card'digital_wallet': Digital Wallet (Apple Pay, Google Pay)'store_credit': Store Credit'gift_card': Gift Card'other': Other
- Required: Yes
- Description: Type of payment method
- Example:
'credit_card'
processor
- Type: ForeignKey → PaymentProcessor
- on_delete:
SET_NULL - Nullable: Yes
- Related Name:
payment_methods - Description: Associated payment processor (null for cash/store credit)
Relationship Pattern:
# Cash doesn't need a processor
cash_method = PaymentMethod.objects.create(
name='Cash',
method_type='cash',
processor=None # No processor needed
)
# Credit cards require a processor
credit_card_method = PaymentMethod.objects.create(
name='Visa/Mastercard',
method_type='credit_card',
processor=stripe_processor # Links to PaymentProcessor
)
is_active
- Type: BooleanField
- Default:
True - Description: Whether this payment method is currently active
- Usage: Filter to show only active payment methods in UI
is_online_available
- Type: BooleanField
- Default:
True - Description: Whether this payment method is available for online purchases
- Example Use Case: Disable cash for online orders
is_pos_available
- Type: BooleanField
- Default:
True - Description: Whether this payment method is available at point-of-sale
- Example Use Case: Some digital wallets might be POS-only
minimum_amount
- Type: DecimalField(10, 2)
- Default:
0.01 - Description: Minimum transaction amount for this payment method
- Example: Credit cards might have $1.00 minimum
maximum_amount
- Type: DecimalField(10, 2)
- Nullable: Yes
- Description: Maximum transaction amount for this payment method
- Example: Cash might have $10,000 maximum for security
Validation:
def clean(self):
if self.maximum_amount and self.minimum_amount > self.maximum_amount:
raise ValidationError('Minimum amount cannot be greater than maximum amount')
configuration
- Type: JSONField
- Default:
{} - Description: Payment method-specific configuration
Example Configuration:
{
"accepted_brands": ["visa", "mastercard", "amex"],
"requires_cvv": true,
"allows_installments": false,
"max_installments": 12
}
display_order
- Type: IntegerField
- Default:
0 - Description: Order in which payment methods are displayed in UI
- Usage: Lower numbers displayed first
Ordering Example:
cash = PaymentMethod.objects.create(name='Cash', display_order=0) # Shown first
credit = PaymentMethod.objects.create(name='Credit Card', display_order=1) # Shown second
store_credit = PaymentMethod.objects.create(name='Store Credit', display_order=2) # Shown third
icon_url
- Type: URLField
- Nullable: Yes
- Description: URL to payment method icon/logo
- Example:
"https://cdn.example.com/icons/visa-logo.png"
description
- Type: TextField
- Blank: Yes
- Description: Additional description for display to users
- Example:
"Accept all major credit cards including Visa, Mastercard, and American Express"
Meta Configuration
class Meta:
db_table = 'payment_method'
ordering = ['display_order', 'name']
indexes = [
models.Index(fields=['method_type'], name='payment_method_type_idx'),
models.Index(fields=['is_active'], name='payment_method_active_idx'),
models.Index(fields=['display_order'], name='payment_method_order_idx'),
]
Ordering: Results ordered by display_order first, then name
Indexes:
method_type: Fast filtering by payment typeis_active: Quick queries for active methodsdisplay_order: Optimized ordering for UI display
Methods
__str__()
Source: models.py:171-173
def __str__(self):
status = 'Active' if self.is_active else 'Inactive'
return f'{self.name} ({self.method_type}) - {status}'
Example Outputs:
"Cash (cash) - Active""Visa/Mastercard (credit_card) - Active""Apple Pay (digital_wallet) - Inactive"
clean()
Source: models.py:175-180
Validation:
- Calls parent
clean()validation - Validates minimum_amount <= maximum_amount
def clean(self):
super().clean()
if self.maximum_amount and self.minimum_amount > self.maximum_amount:
raise ValidationError('Minimum amount cannot be greater than maximum amount')
Usage Examples
Creating Payment Methods:
from nextango.apps.payments.models import PaymentMethod, PaymentProcessor
# Cash payment (no processor needed)
cash = PaymentMethod.objects.create(
name='Cash',
method_type='cash',
processor=None,
is_online_available=False, # Cash only at POS
minimum_amount=Decimal('0.01'),
maximum_amount=Decimal('10000.00'),
display_order=0
)
# Credit card payment (requires processor)
stripe_processor = PaymentProcessor.objects.get(processor_type='stripe')
credit_card = PaymentMethod.objects.create(
name='Visa/Mastercard',
method_type='credit_card',
processor=stripe_processor,
is_online_available=True,
is_pos_available=True,
minimum_amount=Decimal('1.00'),
configuration={
'accepted_brands': ['visa', 'mastercard', 'amex'],
'requires_cvv': True
},
display_order=1,
icon_url='https://cdn.example.com/icons/credit-card.png'
)
Querying Payment Methods:
# Get all active POS payment methods
pos_methods = PaymentMethod.objects.filter(
is_active=True,
is_pos_available=True
).order_by('display_order')
# Get online-available payment methods
online_methods = PaymentMethod.objects.filter(
is_active=True,
is_online_available=True
)
# Get payment methods for specific processor
stripe_methods = PaymentMethod.objects.filter(
processor__processor_type='stripe',
is_active=True
)
PaymentMethodStore
Junction table defining which payment methods are available at specific stores.
Source: models.py:502-518
Purpose: Manages store-specific availability of payment methods, allowing different stores to offer different payment options.
Fields
class PaymentMethodStore(models.Model):
payment_method = models.ForeignKey(
PaymentMethod,
on_delete=models.CASCADE,
related_name='available_at_stores'
)
store = models.ForeignKey(
'stores.Store',
on_delete=models.CASCADE,
related_name='available_payment_methods'
)
payment_method
- Type: ForeignKey → PaymentMethod
- on_delete:
CASCADE(deleting payment method removes availability) - Related Name:
available_at_stores - Description: Payment method being made available
store
- Type: ForeignKey → Store
- on_delete:
CASCADE(deleting store removes availability records) - Related Name:
available_payment_methods - Description: Store where payment method is available
Meta Configuration
class Meta:
db_table = 'payment_method_store'
unique_together = [['payment_method', 'store']]
Unique Constraint: Each payment method can only be linked to each store once.
Usage Examples
Configuring Store Payment Methods:
from nextango.apps.payments.models import PaymentMethod, PaymentMethodStore
from nextango.apps.stores.models import Store
# Get store and payment methods
store_a = Store.objects.get(store_code='STORE-A')
store_b = Store.objects.get(store_code='STORE-B')
cash = PaymentMethod.objects.get(method_type='cash')
credit_card = PaymentMethod.objects.get(method_type='credit_card')
apple_pay = PaymentMethod.objects.get(name='Apple Pay')
# Store A: Cash and credit card only
PaymentMethodStore.objects.create(payment_method=cash, store=store_a)
PaymentMethodStore.objects.create(payment_method=credit_card, store=store_a)
# Store B: All payment methods including Apple Pay
PaymentMethodStore.objects.create(payment_method=cash, store=store_b)
PaymentMethodStore.objects.create(payment_method=credit_card, store=store_b)
PaymentMethodStore.objects.create(payment_method=apple_pay, store=store_b)
Querying Store-Specific Payment Methods:
# Get all payment methods available at a specific store
store = Store.objects.get(store_code='STORE-A')
available_methods = PaymentMethod.objects.filter(
available_at_stores__store=store,
is_active=True
).order_by('display_order')
# Get all stores where a payment method is available
payment_method = PaymentMethod.objects.get(name='Apple Pay')
stores_with_apple_pay = Store.objects.filter(
available_payment_methods__payment_method=payment_method,
is_active=True
)
# Check if payment method is available at specific store
is_available = PaymentMethodStore.objects.filter(
payment_method=apple_pay,
store=store
).exists()
Transaction Models
TransactionPayment
Individual payment record for a sale transaction.
Source: models.py:182-403
Extends: SanityIntegratedModel, AuditableMixin
Purpose: Records payment details for transactions with security, fraud detection, and compliance tracking.
Core Fields
class TransactionPayment(SanityIntegratedModel, AuditableMixin):
transaction = models.ForeignKey('transactions.SaleTransaction', on_delete=models.CASCADE)
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.PROTECT)
purchased_items = models.ManyToManyField('transactions.SaleLineItem', blank=True)
payment_method_type = models.CharField(max_length=50, choices=[...], null=True, blank=True)
amount = models.DecimalField(max_digits=10, decimal_places=2)
tender_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
change_due = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
status = models.CharField(max_length=20, choices=[...], default='pending')
processing_fee = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
net_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
transaction
- Type: ForeignKey → SaleTransaction
- on_delete:
CASCADE(deleting transaction deletes payments) - Related Name:
payment_records - Description: Parent transaction this payment belongs to
- Index: Yes (
payment_transaction_idx)
Relationship Pattern:
# A transaction can have multiple payments (split payment)
transaction = SaleTransaction.objects.get(transactionId='TXN-20251117-ABC123')
payments = transaction.payment_records.all()
# Total paid
total_paid = sum(p.amount for p in payments if p.status == 'succeeded')
payment_method
- Type: ForeignKey → PaymentMethod
- on_delete:
PROTECT(cannot delete payment method if used in payments) - Related Name:
payments - Description: Payment method used for this payment
- Index: Yes (
payment_method_idx)
Why PROTECT: Historical payment records must maintain references to payment methods even if method becomes inactive.
purchased_items
- Type: ManyToManyField → SaleLineItem
- Blank: Yes
- Related Name:
payments - Description: Line items covered by this payment (for split payments)
Split Payment Pattern:
# Customer pays for items 1-2 with cash, items 3-4 with credit card
transaction = SaleTransaction.objects.get(pk=transaction_id)
items_1_2 = transaction.line_items.filter(id__in=[item1_id, item2_id])
items_3_4 = transaction.line_items.filter(id__in=[item3_id, item4_id])
cash_payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=cash_method,
amount=Decimal('50.00')
)
cash_payment.purchased_items.set(items_1_2)
card_payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card_method,
amount=Decimal('75.00')
)
card_payment.purchased_items.set(items_3_4)
payment_method_type
- Type: CharField(50)
- Choices: Same as PaymentMethod.method_type
- Nullable: Yes
- Description: Denormalized payment method type for quick access without JOIN
Denormalization Pattern:
def save(self, *args, **kwargs):
# Auto-populate from payment_method on save
if self.payment_method and not self.payment_method_type:
self.payment_method_type = self.payment_method.method_type
super().save(*args, **kwargs)
Why Denormalize: Allows filtering by payment type without joining to PaymentMethod table:
# Fast query without JOIN
cash_payments = TransactionPayment.objects.filter(payment_method_type='cash')
# vs slower query with JOIN
cash_payments = TransactionPayment.objects.filter(payment_method__method_type='cash')
amount
- Type: DecimalField(10, 2)
- Required: Yes
- Description: Payment amount
- Validation: Must be > 0
tender_amount
- Type: DecimalField(10, 2)
- Nullable: Yes
- Description: Amount tendered by customer (for cash payments)
- Example: Customer pays $20 for $15.50 purchase
change_due
- Type: DecimalField(10, 2)
- Nullable: Yes
- Description: Change due to customer (tender_amount - amount)
- Calculation: Automatically calculated:
tender_amount - amount
Cash Payment Flow:
# Customer pays $50 cash for $42.75 purchase
payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=cash_method,
amount=Decimal('42.75'),
tender_amount=Decimal('50.00'),
change_due=Decimal('7.25') # $50.00 - $42.75
)
status
- Type: CharField(20)
- Choices:
'succeeded': Payment completed successfully'pending': Payment awaiting processing'failed': Payment failed
- Default:
'pending' - Index: Yes (
payment_status_idx)
Status Flow:
pending → succeeded (successful payment)
pending → failed (declined/error)
processing_fee
- Type: DecimalField(10, 2)
- Default:
0.00 - Description: Fee charged by payment processor
- Calculation: Based on PaymentProcessor fee structure
net_amount
- Type: DecimalField(10, 2)
- Default:
0.00 - Description: Net amount after processing fees (amount - processing_fee)
- Auto-calculated: Set in
save()method
Calculation:
def save(self, *args, **kwargs):
if self.amount and self.processing_fee:
self.net_amount = self.amount - self.processing_fee
else:
self.net_amount = self.amount or 0
super().save(*args, **kwargs)
Example:
# $100 payment with $3.20 processing fee
payment = TransactionPayment(
amount=Decimal('100.00'),
processing_fee=Decimal('3.20')
)
payment.save()
# payment.net_amount = $96.80 (automatically calculated)
Processor Integration Fields
processor_transaction_id
- Type: CharField(255)
- Nullable: Yes
- Description: Transaction ID from payment processor (Stripe charge ID, Square payment ID)
- Index: Yes (
payment_processor_id_idx) - Example:
"ch_3NqXYZ..."(Stripe),"sqpmt-abc123..."(Square)
processor_response
- Type: JSONField
- Default:
{} - Description: Full response from payment processor for debugging/audit
Example Stripe Response:
{
"id": "ch_3NqXYZ...",
"object": "charge",
"amount": 10000,
"currency": "usd",
"status": "succeeded",
"payment_method_details": {
"type": "card",
"card": {
"brand": "visa",
"last4": "4242"
}
}
}
risk_score
- Type: DecimalField(5, 2)
- Nullable: Yes
- Description: Fraud risk score (0-100 scale)
- Validation: Must be between 0 and 100
Risk Score Interpretation:
0-30: Low risk (auto-approve)30-70: Medium risk (manual review)70-100: High risk (decline/hold)
Validation:
def clean(self):
if self.risk_score is not None and (self.risk_score < 0 or self.risk_score > 100):
raise ValidationError('Risk score must be between 0 and 100')
Cash Integration Fields
cash_drawer_event
- Type: ForeignKey → CashDrawerEvent
- on_delete:
SET_NULL - Nullable: Yes
- Related Name:
payment_records - Description: Links cash payments to cash drawer events for reconciliation
Cash Payment Pattern:
# Create cash drawer "sale" event when cash payment succeeds
cash_payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=cash_method,
amount=Decimal('42.75'),
status='succeeded'
)
# Create corresponding cash drawer event
from nextango.apps.transactions.models import CashDrawerEvent
drawer_event = CashDrawerEvent.objects.create(
store=transaction.store,
employee=transaction.employee,
event_type='sale',
amount=cash_payment.amount,
transaction=transaction
)
# Link payment to drawer event
cash_payment.cash_drawer_event = drawer_event
cash_payment.save()
processed_at
- Type: DateTimeField
- Nullable: Yes
- Description: Timestamp when payment was successfully processed
- Index: Yes (
payment_processed_idx)
metadata
- Type: JSONField
- Default:
{} - Description: Additional metadata for extensibility
Example Metadata:
{
"device_id": "POS-REGISTER-001",
"cashier_id": "EMP-12345",
"receipt_number": "RCP-20251117-001",
"customer_signature": "data:image/png;base64,..."
}
Security Fields
idempotency_key
- Type: CharField(255)
- Nullable: Yes
- Unique: Yes
- Index: Yes
- Description: Prevents duplicate payment processing
Idempotency Pattern:
import uuid
idempotency_key = str(uuid.uuid4())
# First attempt
payment1 = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card,
amount=Decimal('100.00'),
idempotency_key=idempotency_key
)
# Success
# Network error causes retry with same key
payment2 = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card,
amount=Decimal('100.00'),
idempotency_key=idempotency_key # Same key
)
# Raises IntegrityError - duplicate prevented
device_fingerprint
- Type: CharField(64)
- Blank: Yes
- Description: Unique device identifier for fraud detection
three_d_secure_status
- Type: CharField(20)
- Blank: Yes
- Description: 3D Secure (3DS) verification status
- Values:
'authenticated','attempted','not_supported','failed'
cvv_verification_status
- Type: CharField(20)
- Blank: Yes
- Description: CVV/CVC verification status
- Values:
'match','no_match','not_provided','not_checked'
avs_verification_status
- Type: CharField(20)
- Blank: Yes
- Description: Address Verification System (AVS) status
- Values:
'match','partial_match','no_match','not_checked'
Security Check Example:
payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card,
amount=Decimal('250.00'),
three_d_secure_status='authenticated', # Passed 3DS
cvv_verification_status='match', # CVV matches
avs_verification_status='match', # Address matches
risk_score=Decimal('15.00') # Low risk
)
# High confidence - auto-approve
Compliance Fields
pci_compliance_level
- Type: CharField(20)
- Default:
'level_1' - Description: PCI DSS compliance level
- Values:
'level_1','level_2','level_3','level_4'
data_retention_date
- Type: DateTimeField
- Nullable: Yes
- Description: Date when payment data should be archived/deleted (7 years for PCI)
- Auto-set: On creation if not provided
Auto-calculation:
def save(self, *args, **kwargs):
# Set data retention date (7 years for PCI compliance) only on creation
if self.pk is None and not self.data_retention_date:
from django.utils import timezone
self.data_retention_date = timezone.now() + timezone.timedelta(days=2555) # 7 years
super().save(*args, **kwargs)
PCI Compliance: Payment data must be retained for 7 years then securely deleted.
anonymized_at
- Type: DateTimeField
- Nullable: Yes
- Description: Timestamp when sensitive payment data was anonymized
Anonymization Pattern (example):
def anonymize_payment_data(payment):
"""Anonymize sensitive payment data after retention period."""
payment.last_four_digits = 'XXXX'
payment.card_brand = ''
payment.processor_response = {}
payment.metadata = {}
payment.anonymized_at = timezone.now()
payment.save()
Audit Fields
last_four_digits
- Type: CharField(4)
- Blank: Yes
- Description: Last four digits of payment card
- Example:
"4242"
card_brand
- Type: CharField(20)
- Blank: Yes
- Description: Payment card brand
- Values:
"Visa","Mastercard","Amex","Discover"
funding_source
- Type: CharField(20)
- Blank: Yes
- Description: Funding source type
- Values:
"debit","credit","prepaid"
Display Example:
payment = TransactionPayment.objects.get(pk=payment_id)
display_text = f"{payment.card_brand} ending in {payment.last_four_digits}"
# Output: "Visa ending in 4242"
POS operator fields
operator
- Type: ForeignKey to
users.User - on_delete:
SET_NULL - Nullable: Yes
- Related Name:
payments_taken - Description: The POS operator (cashier) who took this payment
register_id
- Type: CharField(100)
- Blank: Yes
- Description: The POS terminal/register that took this payment, mirrors
CashDrawerEvent.registerId
Both fields are Django-owned, written from the authenticated POS session at payment time. They are intentionally excluded from the Sanity outbound field mappings (payment_sync.py), so they are never pushed to or overwritten by Sanity. They record who took a non-cash payment and on which register, information the cash_drawer_event link only covers for cash payments. Neither field is exposed through TransactionPaymentSerializer, see Serializers.
Meta Configuration
class Meta:
db_table = 'payment'
ordering = ['-created_at']
indexes = [
models.Index(fields=['status'], name='payment_status_idx'),
models.Index(fields=['processor_transaction_id'], name='payment_processor_id_idx'),
models.Index(fields=['transaction'], name='payment_transaction_idx'),
models.Index(fields=['payment_method'], name='payment_method_idx'),
models.Index(fields=['processed_at'], name='payment_processed_idx'),
]
Indexes: Optimized for common queries (status filtering, processor lookup, transaction payments, payment method filtering, processing time queries).
Methods
__str__()
Source: models.py:373-374
def __str__(self):
return f'TransactionPayment \${self.amount} via {self.payment_method.name} - {self.status}'
Example Output: "TransactionPayment $42.75 via Cash - succeeded"
clean()
Source: models.py:376-384
Validation:
- Amount must be > 0
- Risk score must be 0-100
def clean(self):
super().clean()
if self.amount <= 0:
raise ValidationError('Payment amount must be greater than 0')
if self.risk_score is not None and (self.risk_score < 0 or self.risk_score > 100):
raise ValidationError('Risk score must be between 0 and 100')
save()
Source: models.py:385-403
Auto-calculations:
- Denormalize payment_method_type from payment_method
- Calculate net_amount (amount - processing_fee)
- Set data_retention_date (7 years) on creation
def save(self, *args, **kwargs):
# 1. Denormalize payment_method_type
if self.payment_method and not self.payment_method_type:
self.payment_method_type = self.payment_method.method_type
# 2. Calculate net amount
if self.amount and self.processing_fee:
self.net_amount = self.amount - self.processing_fee
else:
self.net_amount = self.amount or 0
# 3. Set data retention date (7 years for PCI compliance) only on creation
if self.pk is None and not self.data_retention_date:
from django.utils import timezone
self.data_retention_date = timezone.now() + timezone.timedelta(days=2555)
super().save(*args, **kwargs)
Usage Examples
Cash Payment:
from nextango.apps.payments.models import TransactionPayment, PaymentMethod
cash_method = PaymentMethod.objects.get(method_type='cash')
transaction = SaleTransaction.objects.get(transactionId='TXN-20251117-ABC123')
payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=cash_method,
amount=Decimal('42.75'),
tender_amount=Decimal('50.00'),
change_due=Decimal('7.25'),
status='succeeded',
processed_at=timezone.now()
)
Credit Card Payment:
credit_card_method = PaymentMethod.objects.get(method_type='credit_card')
payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card_method,
amount=Decimal('125.50'),
status='pending',
idempotency_key=str(uuid.uuid4()),
three_d_secure_status='authenticated',
cvv_verification_status='match',
avs_verification_status='match',
risk_score=Decimal('12.50'),
last_four_digits='4242',
card_brand='Visa',
funding_source='credit'
)
# After processor confirms
payment.status = 'succeeded'
payment.processor_transaction_id = 'ch_3NqXYZ...'
payment.processor_response = {
'id': 'ch_3NqXYZ...',
'status': 'succeeded'
}
payment.processing_fee = Decimal('3.64') # 2.9% + $0.30
payment.processed_at = timezone.now()
payment.save() # net_amount auto-calculated to $121.86
Split Payment:
# Total: $200 - pay $100 cash, $100 credit
transaction = SaleTransaction.objects.get(pk=transaction_id)
# Cash payment
cash_payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=cash_method,
amount=Decimal('100.00'),
status='succeeded'
)
# Credit card payment
card_payment = TransactionPayment.objects.create(
transaction=transaction,
payment_method=credit_card_method,
amount=Decimal('100.00'),
status='succeeded'
)
# Verify total payments match transaction total
total_payments = transaction.payment_records.filter(status='succeeded').aggregate(
total=Sum('amount')
)['total']
assert total_payments == transaction.totalAmount
Refund
Refund processing and tracking for payment reversals.
Source: models.py:405-499
Extends: SanityIntegratedModel, AuditableMixin
Purpose: Tracks refunds issued against original payments with processor integration and return tracking.
Fields
class Refund(SanityIntegratedModel, AuditableMixin):
refund_id = models.CharField(max_length=50, unique=True, null=True, blank=True, editable=False)
payment = models.ForeignKey(TransactionPayment, on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=10, decimal_places=2)
reason = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=20, choices=[...], default='pending')
processor_refund_id = models.CharField(max_length=255, null=True, blank=True)
processed_at = models.DateTimeField(null=True, blank=True)
sales_return = models.ForeignKey('transactions.Return', on_delete=models.SET_NULL, null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
refund_id
- Type: CharField(50)
- Unique: Yes
- Editable: No (auto-generated)
- Format:
REF-YYYYMMDD-XXXXXXXX - Index: Yes
- Description: Human-readable refund identifier
Auto-generation:
def save(self, *args, **kwargs):
if not self.refund_id:
import uuid
from django.utils import timezone
date_str = timezone.now().strftime('%Y%m%d')
unique_id = uuid.uuid4().hex[:8].upper()
self.refund_id = f'REF-{date_str}-{unique_id}'
super().save(*args, **kwargs)
Example IDs:
REF-20251117-A3B4C5D6REF-20251118-F7E8D9C0
payment
- Type: ForeignKey → TransactionPayment
- on_delete:
CASCADE(deleting payment deletes refunds) - Related Name:
refunds - Index: Yes (
refund_payment_idx) - Description: Original payment being refunded
Relationship Pattern:
# Get all refunds for a payment
payment = TransactionPayment.objects.get(pk=payment_id)
refunds = payment.refunds.all()
# Calculate total refunded
total_refunded = refunds.filter(status='succeeded').aggregate(
total=Sum('amount')
)['total'] or Decimal('0.00')
# Calculate remaining refundable amount
refundable = payment.amount - total_refunded
amount
- Type: DecimalField(10, 2)
- Required: Yes
- Description: Refund amount
- Validation: Must be > 0 and <= payment.amount
Validation:
def clean(self):
if self.amount <= 0:
raise ValidationError('Refund amount must be greater than 0')
if self.payment and self.amount > self.payment.amount:
raise ValidationError('Refund amount cannot exceed original payment amount')
reason
- Type: CharField(255)
- Blank: Yes
- Description: Refund reason for audit trail
- Example:
"Customer returned damaged item","Price adjustment","Order cancelled"
status
- Type: CharField(20)
- Choices:
'succeeded': Refund completed'pending': Refund processing'failed': Refund failed
- Default:
'pending' - Index: Yes (
refund_status_idx)
Status Flow:
pending → succeeded (refund processed)
pending → failed (processor error)
processor_refund_id
- Type: CharField(255)
- Nullable: Yes
- Description: Refund ID from payment processor
- Example:
"re_3NqXYZ..."(Stripe),"sqrf-abc123..."(Square)
processed_at
- Type: DateTimeField
- Nullable: Yes
- Index: Yes (
refund_processed_idx) - Description: Timestamp when refund was successfully processed
sales_return
- Type: ForeignKey → Return
- on_delete:
SET_NULL - Nullable: Yes (required for refund-first architecture)
- Related Name:
refunds - Description: Associated sales return document (if refund originated from return)
November 2025 Enhancement - Refund-First Architecture:
The nullable sales_return FK enables a refund-first flow where refunds are processed BEFORE the Return record is created:
# STEP 1: Process refund FIRST (sales_return=None initially)
refund = Refund.objects.create(
payment=original_payment,
amount=total_return_value,
reason=f'Return for transaction {transaction_id}',
sales_return=None, # No return exists yet
status='pending'
)
# STEP 2: Create return record AFTER refunds processed
sales_return = Return.objects.create_return(
original_transaction=original_transaction,
employee=employee,
reason=reason
)
# STEP 3: Link refunds to return
refund.sales_return = sales_return
refund.save(update_fields=['sales_return'])
Why Refund-First:
- Data Integrity: Refunds are financial transactions that should succeed or fail atomically
- Payment Gateway Integration: Payment processors require immediate processing
- Failure Recovery: If refund fails, we haven't created orphaned return records
- Audit Trail: Refunds exist independently with proper timestamps
Related Documentation: See Transactions services.md for complete implementation details.
metadata
- Type: JSONField
- Default:
{} - Description: Additional metadata
Example Metadata:
{
"initiated_by": "MANAGER-12345",
"approval_code": "APR-20251117-001",
"refund_method": "original_payment_method",
"customer_notification_sent": true
}
Meta Configuration
class Meta:
db_table = 'refund'
ordering = ['-created_at']
indexes = [
models.Index(fields=['status'], name='refund_status_idx'),
models.Index(fields=['payment'], name='refund_payment_idx'),
models.Index(fields=['processed_at'], name='refund_processed_idx'),
]
Methods
__str__()
Source: models.py:478-479
def __str__(self):
return f'{self.refund_id} - \${self.amount} - {self.status}' if self.refund_id else f'Refund \${self.amount}'
Example Outputs:
"REF-20251117-A3B4C5D6 - $42.50 - succeeded""Refund $25.00"(before refund_id generated)
save()
Source: models.py:481-489
Auto-generates refund_id using REF-YYYYMMDD-XXXXXXXX format:
def save(self, *args, **kwargs):
if not self.refund_id:
import uuid
from django.utils import timezone
date_str = timezone.now().strftime('%Y%m%d')
unique_id = uuid.uuid4().hex[:8].upper()
self.refund_id = f'REF-{date_str}-{unique_id}'
super().save(*args, **kwargs)
clean()
Source: models.py:491-499
Validation:
- Amount must be > 0
- Amount cannot exceed original payment amount
def clean(self):
super().clean()
if self.amount <= 0:
raise ValidationError('Refund amount must be greater than 0')
if self.payment and self.amount > self.payment.amount:
raise ValidationError('Refund amount cannot exceed original payment amount')
Usage Examples
Full Refund:
from nextango.apps.payments.models import Refund
payment = TransactionPayment.objects.get(pk=payment_id)
refund = Refund.objects.create(
payment=payment,
amount=payment.amount, # Full refund
reason='Order cancelled by customer',
status='pending'
)
# After processor confirms
refund.status = 'succeeded'
refund.processor_refund_id = 're_3NqXYZ...'
refund.processed_at = timezone.now()
refund.save()
Partial Refund:
# Original payment: $100
# Refund: $25 for one returned item
refund = Refund.objects.create(
payment=payment,
amount=Decimal('25.00'), # Partial refund
reason='One item returned - defective',
status='pending'
)
Return-Triggered Refund:
# Customer returns items, refund issued
sales_return = Return.objects.get(returnId='RET-20251117-ABC123')
original_transaction = sales_return.originalSaleId
# Find original payment(s)
original_payments = TransactionPayment.objects.filter(
transaction=original_transaction,
status='succeeded'
).order_by('-created_at')
# Issue refund to most recent payment
refund = Refund.objects.create(
payment=original_payments.first(),
amount=sales_return.refund_amount,
reason=f"Return: {sales_return.reason}",
sales_return=sales_return,
status='pending'
)
Checking Refund Limits:
def get_refundable_amount(payment):
"""Calculate remaining refundable amount for a payment."""
total_refunded = payment.refunds.filter(
status='succeeded'
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
return payment.amount - total_refunded
# Usage
payment = TransactionPayment.objects.get(pk=payment_id)
refundable = get_refundable_amount(payment)
if refund_amount > refundable:
raise ValidationError(f'Cannot refund \${refund_amount}. Only \${refundable} available.')
Integration Models
WebhookEvent
Webhook event tracking for idempotency and audit trail.
Source: models.py:520-589
Purpose: Prevents duplicate processing of payment processor webhooks and provides audit trail for webhook events.
Fields
class WebhookEvent(models.Model):
event_id = models.CharField(max_length=255, unique=True, db_index=True)
event_type = models.CharField(max_length=100)
processor = models.CharField(max_length=50, choices=[...])
status = models.CharField(max_length=20, choices=[...], default='pending')
raw_payload = models.JSONField()
error_message = models.TextField(blank=True)
payment = models.ForeignKey(TransactionPayment, on_delete=models.SET_NULL, null=True, blank=True)
processed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
event_id
- Type: CharField(255)
- Unique: Yes
- Index: Yes (
webhook_event_id_idx) - Description: Unique event ID from payment processor
- Example:
"evt_1NqXYZ..."(Stripe),"sq_evt_abc123..."(Square)
Idempotency Pattern:
# Webhook received
event_id = webhook_payload.get('id')
# Check if already processed
if WebhookEvent.objects.filter(event_id=event_id).exists():
logger.info(f"Webhook {event_id} already processed - skipping")
return HttpResponse(status=200) # Return success to prevent retries
# First time - create event
webhook_event = WebhookEvent.objects.create(
event_id=event_id,
event_type=webhook_payload.get('type'),
processor='stripe',
raw_payload=webhook_payload,
status='pending'
)
event_type
- Type: CharField(100)
- Description: Event type from payment processor
- Stripe Examples:
"payment_intent.succeeded","charge.refunded","payment_intent.payment_failed" - Square Examples:
"payment.created","payment.updated","refund.created"
processor
- Type: CharField(50)
- Choices:
'stripe': Stripe'square': Square'paypal': PayPal'custom': Custom processor'elavon': Elavon
- Index: Yes (composite with
status) - Description: Payment processor that sent webhook
status
- Type: CharField(20)
- Choices:
'pending': Awaiting processing'processed': Successfully processed'failed': Processing failed
- Default:
'pending' - Index: Yes (composite with
processor)
Status Flow:
pending → processed (webhook handled successfully)
pending → failed (processing error)
raw_payload
- Type: JSONField
- Required: Yes
- Description: Complete webhook payload for debugging and audit
Example Stripe Payload:
{
"id": "evt_1NqXYZ...",
"object": "event",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_3NqXYZ...",
"amount": 10000,
"currency": "usd",
"status": "succeeded"
}
},
"created": 1699564800
}
error_message
- Type: TextField
- Blank: Yes
- Description: Error message if processing failed
Error Tracking:
try:
# Process webhook
process_payment_webhook(webhook_event)
webhook_event.status = 'processed'
webhook_event.processed_at = timezone.now()
except Exception as e:
webhook_event.status = 'failed'
webhook_event.error_message = str(e)
logger.error(f"Webhook processing failed: {e}")
finally:
webhook_event.save()
payment
- Type: ForeignKey → TransactionPayment
- on_delete:
SET_NULL - Nullable: Yes
- Related Name:
webhook_events - Description: Linked payment for audit trail
Audit Trail:
# After processing webhook, link to payment
payment = TransactionPayment.objects.get(processor_transaction_id=charge_id)
webhook_event.payment = payment
webhook_event.save()
# Later: View all webhook events for a payment
payment_webhooks = payment.webhook_events.all().order_by('-created_at')
processed_at
- Type: DateTimeField
- Nullable: Yes
- Index: Yes (
refund_processed_idx) - Description: When webhook was successfully processed
created_at
- Type: DateTimeField
- Auto Now Add: Yes
- Index: Yes (
webhook_created_idx) - Description: When webhook was received
Meta Configuration
class Meta:
db_table = 'webhook_event'
ordering = ['-created_at']
indexes = [
models.Index(fields=['event_id'], name='webhook_event_id_idx'),
models.Index(fields=['processor', 'status'], name='webhook_processor_status_idx'),
models.Index(fields=['created_at'], name='webhook_created_idx'),
]
Composite Index: processor + status for queries like "all failed Stripe webhooks".
Methods
__str__()
Source: models.py:588-589
def __str__(self):
return f'{self.processor} - {self.event_type} ({self.event_id}) - {self.status}'
Example Outputs:
"stripe - payment_intent.succeeded (evt_1NqXYZ...) - processed""square - payment.created (sq_evt_abc123...) - pending""paypal - payment.capture.completed (PAYID-ABC123) - failed"
Usage Examples
Stripe Webhook Processing:
from nextango.apps.payments.models import WebhookEvent, TransactionPayment
import stripe
@csrf_exempt
def stripe_webhook_view(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
# Check idempotency
event_id = event['id']
if WebhookEvent.objects.filter(event_id=event_id).exists():
return HttpResponse(status=200) # Already processed
# Create webhook event record
webhook_event = WebhookEvent.objects.create(
event_id=event_id,
event_type=event['type'],
processor='stripe',
raw_payload=event,
status='pending'
)
# Process event
try:
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
payment = TransactionPayment.objects.get(
processor_transaction_id=payment_intent['id']
)
payment.status = 'succeeded'
payment.processed_at = timezone.now()
payment.save()
# Link webhook to payment
webhook_event.payment = payment
webhook_event.status = 'processed'
webhook_event.processed_at = timezone.now()
webhook_event.save()
return HttpResponse(status=200)
except Exception as e:
webhook_event.status = 'failed'
webhook_event.error_message = str(e)
webhook_event.save()
return HttpResponse(status=500)
Retry Failed Webhooks:
def retry_failed_webhooks():
"""Retry failed webhook processing."""
failed_webhooks = WebhookEvent.objects.filter(
status='failed',
created_at__gte=timezone.now() - timedelta(hours=24)
)
for webhook in failed_webhooks:
try:
# Re-process webhook
process_webhook_event(webhook)
webhook.status = 'processed'
webhook.processed_at = timezone.now()
webhook.error_message = ''
webhook.save()
except Exception as e:
webhook.error_message = f"Retry failed: {str(e)}"
webhook.save()
Audit Trail Query:
# Get all Stripe webhooks from last 7 days
recent_stripe_webhooks = WebhookEvent.objects.filter(
processor='stripe',
created_at__gte=timezone.now() - timedelta(days=7)
).order_by('-created_at')
# Get failed webhooks needing attention
failed_webhooks = WebhookEvent.objects.filter(
status='failed'
).select_related('payment')
# Get all webhooks for a specific payment
payment = TransactionPayment.objects.get(pk=payment_id)
payment_webhook_history = payment.webhook_events.all().order_by('-created_at')
Model Relationships
Entity Relationship Diagram
PaymentProcessor (1) ───────< (∞) PaymentMethod
│
│ (1)
│
↓
(∞) PaymentMethodStore (∞) ───> (1) Store
│
│ (1)
│
↓
(∞) TransactionPayment ───> (1) SaleTransaction
│ │
│ │
↓ ↓
(∞) Refund (∞) SaleLineItem
│ ↑
│ │
↓ │ (M:M)
(1) Return ──────────────────────┘
TransactionPayment (1) <───────< (∞) WebhookEvent
TransactionPayment (∞) >───────< (∞) SaleLineItem (via purchased_items M:M)
TransactionPayment (1) ───────> (1) CashDrawerEvent
Relationship Details
PaymentProcessor → PaymentMethod (One-to-Many)
Relationship: One processor can have many payment methods.
Example:
stripe_processor = PaymentProcessor.objects.get(processor_type='stripe')
# All payment methods using Stripe
stripe_methods = stripe_processor.payment_methods.all()
# Example: Credit Card, Debit Card, Digital Wallet (all via Stripe)
PaymentMethod → PaymentMethodStore → Store (Many-to-Many through)
Relationship: Payment methods available at specific stores.
Example:
# Payment method at specific stores
apple_pay = PaymentMethod.objects.get(name='Apple Pay')
stores_with_apple_pay = Store.objects.filter(
available_payment_methods__payment_method=apple_pay
)
# Store's available payment methods
store = Store.objects.get(store_code='STORE-A')
available_methods = PaymentMethod.objects.filter(
available_at_stores__store=store,
is_active=True
)
TransactionPayment → SaleTransaction (Many-to-One)
Relationship: Multiple payments can belong to one transaction (split payment).
Example:
transaction = SaleTransaction.objects.get(transactionId='TXN-20251117-ABC123')
# All payments for transaction
payments = transaction.payment_records.all()
# Total paid
total_paid = payments.filter(status='succeeded').aggregate(
total=Sum('amount')
)['total']
TransactionPayment → SaleLineItem (Many-to-Many)
Relationship: Payments can cover specific line items (for split payments by item).
Example:
# Payment 1 covers items 1-3
payment1 = TransactionPayment.objects.get(pk=payment1_id)
items_1_3 = SaleLineItem.objects.filter(id__in=[item1_id, item2_id, item3_id])
payment1.purchased_items.set(items_1_3)
# Payment 2 covers items 4-5
payment2 = TransactionPayment.objects.get(pk=payment2_id)
items_4_5 = SaleLineItem.objects.filter(id__in=[item4_id, item5_id])
payment2.purchased_items.set(items_4_5)
# Query which payments cover a specific item
line_item = SaleLineItem.objects.get(pk=item_id)
payments_for_item = line_item.payments.all()
Refund → TransactionPayment (Many-to-One)
Relationship: Multiple refunds can be issued against one payment (partial refunds).
Example:
payment = TransactionPayment.objects.get(pk=payment_id) # $100 payment
# Issue two partial refunds
refund1 = Refund.objects.create(payment=payment, amount=Decimal('25.00'))
refund2 = Refund.objects.create(payment=payment, amount=Decimal('30.00'))
# Total refunded
total_refunded = payment.refunds.filter(status='succeeded').aggregate(
total=Sum('amount')
)['total'] # $55.00
# Remaining refundable
refundable = payment.amount - total_refunded # $45.00
Refund → Return (Many-to-One)
Relationship: Refunds can be associated with returns.
Example:
sales_return = Return.objects.get(returnId='RET-20251117-ABC123')
# All refunds for this return
refunds = sales_return.refunds.all()
# Multiple refunds if original transaction had split payments
for refund in refunds:
print(f"Refund {refund.refund_id}: \${refund.amount} to {refund.payment.payment_method.name}")
WebhookEvent → TransactionPayment (Many-to-One)
Relationship: Multiple webhook events can relate to one payment (status updates, confirmations).
Example:
payment = TransactionPayment.objects.get(pk=payment_id)
# All webhook events for this payment
webhooks = payment.webhook_events.all().order_by('-created_at')
# Example events: payment_intent.created, payment_intent.succeeded, charge.succeeded
for webhook in webhooks:
print(f"{webhook.event_type} at {webhook.created_at} - {webhook.status}")
TransactionPayment → CashDrawerEvent (Many-to-One)
Relationship: Cash payments linked to cash drawer events for reconciliation.
Example:
# All cash payments linked to a drawer event
drawer_event = CashDrawerEvent.objects.get(pk=drawer_event_id)
cash_payments = drawer_event.payment_records.all()
# Calculate expected cash in drawer
expected_cash = cash_payments.filter(status='succeeded').aggregate(
total=Sum('amount')
)['total']
# Compare with counted amount
variance = drawer_event.counted_amount - expected_cash
Related Documentation
- Serializers - Payment serialization and validation
- Views - Payment API endpoints
- Services - Payment processing business logic
- Webhooks - Payment processor webhook handling
- Signals - Payment sync and event handling
- Transactions Models - SaleTransaction, Return models
- Stores Models - Store model