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

  1. Overview
  2. Configuration Models
  3. Transaction Models
  4. Integration Models
  5. Model Relationships

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 type
  • is_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_type must be one of the declared choices (stripe, square, paypal, custom, elavon)
  • default_currency must be exactly 3 characters (via clean() 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:

  1. Calls parent clean() (SanityIntegratedModel validation)
  2. 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 type
  • is_active: Quick queries for active methods
  • display_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:

  1. Calls parent clean() validation
  2. 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:

  1. Amount must be > 0
  2. 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:

  1. Denormalize payment_method_type from payment_method
  2. Calculate net_amount (amount - processing_fee)
  3. 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-A3B4C5D6
  • REF-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:

  1. Amount must be > 0
  2. 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

Was this page helpful?