Payments Domain - Serializers Documentation

Complete documentation of Payments domain serialization layer.

Source: api/nextango/apps/payments/serializers.py Last Updated: 2026-07-08

TransactionPayment.operator and TransactionPayment.register_id are Django-owned model fields (see Models) that are not included in TransactionPaymentSerializer.Meta.fields, they are not exposed through the standard payments API response.


Table of Contents

  1. Overview
  2. Model Serializers
  3. Simplified Serializers
  4. Create Serializers
  5. Field Transformation Patterns
  6. Reference Handling

Overview

The Payments serializers handle bidirectional data transformation between Django models and Sanity CMS, with validation and nested relationship processing.

Key Serializers:

  • 4 Model Serializers: Full serialization for API responses and webhook processing
  • 2 Simplified Serializers: Lightweight serializers for nested relationships
  • 4 Create Serializers: Validation-focused serializers for creating new records

Architectural Patterns:

  • Bidirectional Field Mapping: Automatic camelCase ↔ snake_case conversion
  • Reference Resolution: Sanity _ref pattern converted to Django foreign keys
  • Nested Processing: Automatic handling of one-to-many/many-to-many relationships
  • Computed Fields: Counts, totals, and aggregations
  • Webhook Integration: to_internal_value() override for Sanity webhook data
  • Validation: Range checks, cross-field validation, business rule enforcement

Model Serializers

PaymentProcessorSerializer

Full serialization for PaymentProcessor with computed payment methods count.

Source: serializers.py:8-38

Purpose: Serializes payment processor configuration for API responses and webhook processing.


Fields

class PaymentProcessorSerializer(serializers.ModelSerializer):
    payment_methods_count = serializers.SerializerMethodField()

    class Meta:
        model = PaymentProcessor
        fields = [
            'id', 'sanity_id', 'name', 'processor_type', 'is_active', 'is_test_mode',
            'configuration', 'processing_fee_percentage', 'processing_fee_fixed',
            'default_currency', 'payment_methods_count', 'created_at', 'updated_at'
        ]
        read_only_fields = ['id', 'created_at', 'updated_at', 'payment_methods_count']

Model Fields
FieldTypeRead-OnlyDescription
idUUIDYesDjango primary key
sanity_idStringNoSanity document ID
nameStringNoProcessor display name
processor_typeStringNoProcessor type (stripe, square, paypal)
is_activeBooleanNoWhether processor is active
is_test_modeBooleanNoWhether in test/sandbox mode
configurationJSONNoNon-sensitive configuration
processing_fee_percentageDecimalNoFee percentage (e.g., 0.0290 for 2.9%)
processing_fee_fixedDecimalNoFixed fee per transaction
default_currencyStringNo3-letter currency code
created_atDateTimeYesCreation timestamp
updated_atDateTimeYesLast update timestamp

Computed Fields
payment_methods_count

Type: SerializerMethodField (Integer) Read-Only: Yes

Source: serializers.py:22-23

def get_payment_methods_count(self, obj):
    return obj.payment_methods.count()

Description: Total number of payment methods using this processor.

Example Output:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "sanity_id": "processor-stripe-prod",
  "name": "Stripe Production",
  "processor_type": "stripe",
  "is_active": true,
  "is_test_mode": false,
  "processing_fee_percentage": "0.0290",
  "processing_fee_fixed": "0.30",
  "default_currency": "USD",
  "payment_methods_count": 3,
  "created_at": "2025-01-15T10:30:00Z",
  "updated_at": "2025-01-15T10:30:00Z"
}

Methods

to_internal_value(data)

Source: serializers.py:25-38

Purpose: Convert Sanity webhook camelCase field names to Django snake_case.

def to_internal_value(self, data):
    """Handle nested Sanity webhook data with field name conversion."""
    from ..integrations.sync.field_mappings import bidirectional_field_mapper

    # Convert camelCase to snake_case for payments domain
    converted_data = {}
    for key, value in data.items():
        if key in ['_id', '_type', '_rev', '_createdAt', '_updatedAt']:
            continue

        django_field = bidirectional_field_mapper.get_django_field_name(key, 'payments')
        converted_data[django_field] = value

    return super().to_internal_value(converted_data)

Field Conversion Examples:

  • processorTypeprocessor_type
  • isActiveis_active
  • isTestModeis_test_mode
  • processingFeePercentageprocessing_fee_percentage
  • processingFeeFixedprocessing_fee_fixed
  • defaultCurrencydefault_currency

Ignored Sanity Fields:

  • _id: Mapped to sanity_id elsewhere
  • _type: Document type metadata
  • _rev: Revision metadata
  • _createdAt: Mapped to created_at elsewhere
  • _updatedAt: Mapped to updated_at elsewhere

PaymentMethodSerializer

Full serialization for PaymentMethod with store availability and processor details.

Source: serializers.py:41-150

Purpose: Serializes payment methods with computed counts and nested store relationships.


Fields

class PaymentMethodSerializer(serializers.ModelSerializer):
    processor_name = serializers.CharField(source='processor.name', read_only=True, required=False)
    payments_count = serializers.SerializerMethodField()
    available_stores_count = serializers.SerializerMethodField()
    available_stores = serializers.SerializerMethodField()

    class Meta:
        model = PaymentMethod
        fields = [
            'id', 'sanity_id', 'name', 'method_type', 'processor', 'processor_name',
            'is_active', 'is_online_available', 'is_pos_available',
            'minimum_amount', 'maximum_amount', 'configuration', 'display_order',
            'icon_url', 'description', 'payments_count', 'available_stores_count',
            'available_stores', 'created_at', 'updated_at'
        ]
        read_only_fields = [
            'id', 'created_at', 'updated_at', 'processor_name',
            'payments_count', 'available_stores_count', 'available_stores'
        ]

Model Fields
FieldTypeRead-OnlyDescription
idUUIDYesDjango primary key
sanity_idStringNoSanity document ID
nameStringNoPayment method display name
method_typeStringNoType (cash, credit_card, etc.)
processorUUIDNoForeign key to PaymentProcessor
is_activeBooleanNoWhether method is active
is_online_availableBooleanNoAvailable for online purchases
is_pos_availableBooleanNoAvailable at point-of-sale
minimum_amountDecimalNoMinimum transaction amount
maximum_amountDecimalNoMaximum transaction amount
configurationJSONNoMethod-specific configuration
display_orderIntegerNoUI display order
icon_urlURLNoPayment method icon URL
descriptionTextNoAdditional description
created_atDateTimeYesCreation timestamp
updated_atDateTimeYesLast update timestamp

Computed Fields
processor_name

Type: CharField (from related object) Read-Only: Yes Source: processor.name

processor_name = serializers.CharField(source='processor.name', read_only=True, required=False)

Description: Denormalized processor name for display without extra query.


payments_count

Type: SerializerMethodField (Integer) Read-Only: Yes

Source: serializers.py:63-64

def get_payments_count(self, obj):
    return obj.payments.count()

Description: Total number of payments using this method.


available_stores_count

Type: SerializerMethodField (Integer) Read-Only: Yes

Source: serializers.py:66-67

def get_available_stores_count(self, obj):
    return obj.available_at_stores.count()

Description: Number of stores where this payment method is available.


available_stores

Type: SerializerMethodField (List of dicts) Read-Only: Yes

Source: serializers.py:69-82

def get_available_stores(self, obj):
    """Return available stores with reference format matching Sanity."""
    stores = []
    for store_association in obj.available_at_stores.all():
        store = store_association.store
        stores.append({
            "_key": f"store_{store.sanity_id}",
            "_ref": store.sanity_id,
            "_type": "reference",
            "store_id": store.id,
            "store_name": store.name,
            "is_active": store.is_active
        })
    return stores

Description: List of stores with Sanity-compatible reference format.

Output Format:

{
  "available_stores": [
    {
      "_key": "store_store-columbus-001",
      "_ref": "store-columbus-001",
      "_type": "reference",
      "store_id": "uuid-here",
      "store_name": "Columbus Main Street",
      "is_active": true
    }
  ]
}

Sanity Compatibility: The _key, _ref, _type pattern matches Sanity's reference array format for bidirectional sync.


Example Output
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "sanity_id": "payment-method-credit-card",
  "name": "Visa/Mastercard",
  "method_type": "credit_card",
  "processor": "processor-uuid",
  "processor_name": "Stripe Production",
  "is_active": true,
  "is_online_available": true,
  "is_pos_available": true,
  "minimum_amount": "1.00",
  "maximum_amount": null,
  "configuration": {
    "accepted_brands": ["visa", "mastercard", "amex"],
    "requires_cvv": true
  },
  "display_order": 1,
  "icon_url": "https://cdn.example.com/icons/credit-card.png",
  "description": "All major credit cards",
  "payments_count": 1247,
  "available_stores_count": 5,
  "available_stores": [
    {
      "_key": "store_store-001",
      "_ref": "store-001",
      "_type": "reference",
      "store_id": "uuid",
      "store_name": "Main Street Store",
      "is_active": true
    }
  ],
  "created_at": "2025-01-15T10:30:00Z",
  "updated_at": "2025-01-15T10:30:00Z"
}

Methods

to_internal_value(data)

Source: serializers.py:84-104

Purpose: Convert Sanity webhook data and resolve processor references.

def to_internal_value(self, data):
    """Handle nested Sanity webhook data with field name conversion."""
    from ..integrations.sync.field_mappings import bidirectional_field_mapper

    # Convert camelCase to snake_case for payments domain
    converted_data = {}
    for key, value in data.items():
        if key in ['_id', '_type', '_rev', '_createdAt', '_updatedAt']:
            continue

        django_field = bidirectional_field_mapper.get_django_field_name(key, 'payments')
        converted_data[django_field] = value

    # Handle processor reference
    processor_ref = converted_data.get('processor')
    if isinstance(processor_ref, dict) and '_ref' in processor_ref:
        PaymentProcessor = apps.get_model('payments', 'PaymentProcessor')
        processor_instance = PaymentProcessor.objects.filter(sanity_id=processor_ref['_ref']).first()
        converted_data['processor'] = processor_instance.pk if processor_instance else None

    return super().to_internal_value(converted_data)

Reference Resolution:

Input (from Sanity webhook):

{
  "processor": {
    "_ref": "processor-stripe-prod",
    "_type": "reference"
  }
}

Converted:

{
  "processor": UUID("processor-django-pk")  # Django foreign key
}

create(validated_data)

Source: serializers.py:106-116

Purpose: Create payment method and process store availability from webhook.

def create(self, validated_data):
    """Create payment method and handle store availability."""
    webhook_data = self.context.get('request_data', {})

    # Create the payment method instance
    payment_method = PaymentMethod.objects.create(**validated_data)

    # Process available stores
    self._process_available_stores(payment_method, webhook_data.get('availableAtStores', []))

    return payment_method

Context Required: request_data in serializer context must contain full webhook payload.


update(instance, validated_data)

Source: serializers.py:118-128

Purpose: Update payment method and process store availability changes.

def update(self, instance, validated_data):
    """Update payment method and handle store availability."""
    webhook_data = self.context.get('request_data', {})

    # Update the payment method instance
    payment_method = super().update(instance, validated_data)

    # Process available stores
    self._process_available_stores(payment_method, webhook_data.get('availableAtStores', []))

    return payment_method

_process_available_stores(payment_method, stores_data)

Source: serializers.py:130-149

Purpose: Process many-to-many store availability relationships from Sanity references.

def _process_available_stores(self, payment_method, stores_data):
    """Process available stores for payment method."""
    if not isinstance(stores_data, list):
        return

    PaymentMethodStore = apps.get_model('payments', 'PaymentMethodStore')
    Store = apps.get_model('stores', 'Store')

    # Clear existing store associations
    payment_method.available_at_stores.all().delete()

    # Create new associations
    for store_ref in stores_data:
        if isinstance(store_ref, dict) and '_ref' in store_ref:
            store_instance = Store.objects.filter(sanity_id=store_ref['_ref']).first()
            if store_instance:
                PaymentMethodStore.objects.create(
                    payment_method=payment_method,
                    store=store_instance
                )

Input Format (from Sanity):

{
  "availableAtStores": [
    {
      "_key": "key1",
      "_ref": "store-001",
      "_type": "reference"
    },
    {
      "_key": "key2",
      "_ref": "store-002",
      "_type": "reference"
    }
  ]
}

Processing:

  1. Delete all existing PaymentMethodStore associations
  2. Resolve each store _ref to Django Store instance by sanity_id
  3. Create new PaymentMethodStore junction records

Idempotency: Always clears and recreates associations to match Sanity state.


TransactionPaymentSerializer

Full serialization for TransactionPayment with refund tracking and computed totals.

Source: serializers.py:175-296

Purpose: Serializes payment records with full refund information and processor details.


Fields

class TransactionPaymentSerializer(serializers.ModelSerializer):
    transaction_id = serializers.CharField(source='transaction.transactionId', read_only=True, required=False)
    payment_method_name = serializers.CharField(source='payment_method.name', read_only=True)
    payment_method_type = serializers.CharField(source='payment_method.method_type', read_only=True)
    refunds = RefundSerializer(many=True, read_only=True)
    refunds_count = serializers.SerializerMethodField()
    total_refunded = serializers.SerializerMethodField()

    class Meta:
        model = TransactionPayment
        fields = [
            'id', 'sanity_id', 'transaction', 'transaction_id', 'payment_method',
            'payment_method_name', 'payment_method_type', 'amount', 'tender_amount',
            'change_due', 'status', 'processing_fee', 'net_amount',
            'processor_transaction_id', 'processor_response', 'risk_score',
            'cash_drawer_event', 'processed_at', 'metadata', 'refunds',
            'refunds_count', 'total_refunded', 'created_at', 'updated_at'
        ]
        read_only_fields = [
            'id', 'created_at', 'updated_at', 'transaction_id', 'payment_method_name',
            'payment_method_type', 'refunds', 'refunds_count', 'total_refunded',
            'net_amount', 'change_due'
        ]

Model Fields
FieldTypeRead-OnlyDescription
idUUIDYesDjango primary key
sanity_idStringNoSanity document ID
transactionUUIDNoForeign key to SaleTransaction
payment_methodUUIDNoForeign key to PaymentMethod
amountDecimalNoPayment amount
tender_amountDecimalNoAmount tendered (cash)
change_dueDecimalYesChange due (auto-calculated)
statusStringNoPayment status (succeeded/pending/failed)
processing_feeDecimalNoProcessor fee amount
net_amountDecimalYesNet after fees (auto-calculated)
processor_transaction_idStringNoProcessor's transaction ID
processor_responseJSONNoFull processor response
risk_scoreDecimalNoFraud risk score (0-100)
cash_drawer_eventUUIDNoForeign key to CashDrawerEvent
processed_atDateTimeNoWhen payment was processed
metadataJSONNoAdditional metadata
created_atDateTimeYesCreation timestamp
updated_atDateTimeYesLast update timestamp

Computed Fields
transaction_id

Type: CharField (from related object) Read-Only: Yes Source: transaction.transactionId (the SaleTransaction model's camelCase field, not a snake_case transaction_id)

transaction_id = serializers.CharField(source='transaction.transactionId', read_only=True, required=False)

Description: Human-readable transaction ID (e.g., TXN-20251117-ABC123).


payment_method_name

Type: CharField (from related object) Read-Only: Yes Source: payment_method.name

payment_method_name = serializers.CharField(source='payment_method.name', read_only=True)

Description: Payment method display name (e.g., "Visa/Mastercard").


payment_method_type

Type: CharField (from related object) Read-Only: Yes Source: payment_method.method_type

payment_method_type = serializers.CharField(source='payment_method.method_type', read_only=True)

Description: Payment method type (e.g., "credit_card").


refunds

Type: Nested RefundSerializer (many=True) Read-Only: Yes

refunds = RefundSerializer(many=True, read_only=True)

Description: All refunds associated with this payment (nested full serialization).


refunds_count

Type: SerializerMethodField (Integer) Read-Only: Yes

Source: serializers.py:201-202

def get_refunds_count(self, obj):
    return obj.refunds.count()

Description: Total number of refunds for this payment.


total_refunded

Type: SerializerMethodField (Decimal) Read-Only: Yes

Source: serializers.py:204-205

def get_total_refunded(self, obj):
    return sum(refund.amount for refund in obj.refunds.filter(status='succeeded'))

Description: Total amount refunded (only succeeded refunds).

Calculation: Sums amount from all refunds with status='succeeded'.


Example Output
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "sanity_id": "payment-abc123",
  "transaction": "transaction-uuid",
  "transaction_id": "TXN-20251117-ABC123",
  "payment_method": "payment-method-uuid",
  "payment_method_name": "Visa/Mastercard",
  "payment_method_type": "credit_card",
  "amount": "125.50",
  "tender_amount": null,
  "change_due": null,
  "status": "succeeded",
  "processing_fee": "3.64",
  "net_amount": "121.86",
  "processor_transaction_id": "ch_3NqXYZ...",
  "processor_response": {
    "id": "ch_3NqXYZ...",
    "status": "succeeded"
  },
  "risk_score": "12.50",
  "cash_drawer_event": null,
  "processed_at": "2025-11-17T14:30:00Z",
  "metadata": {
    "device_id": "POS-001"
  },
  "refunds": [
    {
      "id": "refund-uuid",
      "refund_id": "REF-20251118-DEF456",
      "amount": "25.00",
      "status": "succeeded"
    }
  ],
  "refunds_count": 1,
  "total_refunded": "25.00",
  "created_at": "2025-11-17T14:30:00Z",
  "updated_at": "2025-11-18T10:15:00Z"
}

Methods

to_internal_value(data)

Source: serializers.py:207-250

Purpose: Convert Sanity webhook data and resolve foreign key references.

def to_internal_value(self, data):
    """Handle nested Sanity webhook data with field name conversion."""
    from ..integrations.sync.field_mappings import bidirectional_field_mapper

    # Convert camelCase to snake_case for payments domain
    converted_data = {}
    for key, value in data.items():
        if key in ['_id', '_type', '_rev', '_createdAt', '_updatedAt']:
            continue

        django_field = bidirectional_field_mapper.get_django_field_name(key, 'payments')
        converted_data[django_field] = value

    # Handle transaction reference
    transaction_ref = converted_data.get('transaction')
    if isinstance(transaction_ref, dict) and '_ref' in transaction_ref:
        SalesTransaction = apps.get_model('transactions', 'SalesTransaction')
        transaction_instance = SalesTransaction.objects.filter(sanity_id=transaction_ref['_ref']).first()
        converted_data['transaction'] = transaction_instance.pk if transaction_instance else None

    # Handle payment method reference
    payment_method_ref = converted_data.get('payment_method')
    if isinstance(payment_method_ref, dict) and '_ref' in payment_method_ref:
        PaymentMethod = apps.get_model('payments', 'PaymentMethod')
        payment_method_instance = PaymentMethod.objects.filter(sanity_id=payment_method_ref['_ref']).first()
        converted_data['payment_method'] = payment_method_instance.pk if payment_method_instance else None

    # Handle cash drawer event reference
    cash_drawer_ref = converted_data.get('cash_drawer_event')
    if isinstance(cash_drawer_ref, dict) and '_ref' in cash_drawer_ref:
        CashDrawerEvent = apps.get_model('transactions', 'CashDrawerEvent')
        cash_drawer_instance = CashDrawerEvent.objects.filter(sanity_id=cash_drawer_ref['_ref']).first()
        converted_data['cash_drawer_event'] = cash_drawer_instance.pk if cash_drawer_instance else None

    # Handle processor response as object
    processor_response_ref = converted_data.get('processor_response')
    if isinstance(processor_response_ref, dict) and '_ref' in processor_response_ref:
        # If it's a reference to another document, store the reference ID
        converted_data['processor_response'] = {'reference_id': processor_response_ref['_ref']}
    elif isinstance(processor_response_ref, dict):
        # If it's inline processor response data, store it directly
        converted_data['processor_response'] = processor_response_ref

    return super().to_internal_value(converted_data)

Reference Resolution:

  • transaction: Resolves to SaleTransaction by sanity_id
  • payment_method: Resolves to PaymentMethod by sanity_id
  • cash_drawer_event: Resolves to CashDrawerEvent by sanity_id
  • processor_response: Handles both inline JSON and reference patterns

create(validated_data)

Source: serializers.py:252-262

Purpose: Create payment and process nested refunds from webhook.

def create(self, validated_data):
    """Create payment and handle nested refunds."""
    webhook_data = self.context.get('request_data', {})

    # Create the payment instance
    payment = Payment.objects.create(**validated_data)

    # Process refunds
    self._process_refunds(payment, webhook_data.get('refunds', []))

    return payment

update(instance, validated_data)

Source: serializers.py:264-274

Purpose: Update payment and process refund changes.

def update(self, instance, validated_data):
    """Update payment and handle nested refunds."""
    webhook_data = self.context.get('request_data', {})

    # Update the payment instance
    payment = super().update(instance, validated_data)

    # Process refunds
    self._process_refunds(payment, webhook_data.get('refunds', []))

    return payment

_process_refunds(payment, refunds_data)

Source: serializers.py:276-295

Purpose: Create placeholder refunds from Sanity reference array.

def _process_refunds(self, payment, refunds_data):
    """Process refunds for payment."""
    if not isinstance(refunds_data, list):
        return

    Refund = apps.get_model('payments', 'Refund')

    # Process each refund reference
    for refund_ref in refunds_data:
        if isinstance(refund_ref, dict) and '_ref' in refund_ref:
            # Check if refund already exists with this sanity_id
            existing_refund = Refund.objects.filter(sanity_id=refund_ref['_ref']).first()
            if not existing_refund:
                # Create placeholder refund - actual refund data will come via separate webhook
                Refund.objects.create(
                    payment=payment,
                    amount=0,  # Will be updated when refund webhook comes
                    status='pending',
                    sanity_id=refund_ref['_ref']
                )

Placeholder Pattern:

Sanity webhook may send payment with refund references before refund document webhooks arrive:

{
  "refunds": [
    {
      "_ref": "refund-abc123",
      "_type": "reference"
    }
  ]
}

Django creates placeholder:

Refund(
    payment=payment_instance,
    amount=Decimal('0.00'),  # Placeholder
    status='pending',
    sanity_id='refund-abc123'
)

Later, when refund webhook arrives, it updates the placeholder with actual data.

Idempotency: Checks existing_refund to prevent duplicate creation.


RefundSerializer

Full serialization for Refund with payment context.

Source: serializers.py:152-173

Purpose: Serializes refund records with payment amount and transaction references.


Fields

class RefundSerializer(serializers.ModelSerializer):
    payment_amount = serializers.DecimalField(
        source='payment.amount',
        max_digits=10,
        decimal_places=2,
        read_only=True
    )
    transaction_id = serializers.CharField(source='payment.transaction.transactionId', read_only=True)

    class Meta:
        model = Refund
        fields = [
            'id', 'refund_id', 'sanity_id', 'payment', 'payment_amount', 'transaction_id',
            'amount', 'reason', 'status', 'processor_refund_id', 'processed_at',
            'sales_return', 'metadata', 'created_at', 'updated_at'
        ]
        read_only_fields = [
            'id', 'refund_id', 'processed_at', 'created_at', 'updated_at', 'payment_amount', 'transaction_id'
        ]

Model Fields
FieldTypeRead-OnlyDescription
idUUIDYesDjango primary key
refund_idStringYesAuto-generated ID (REF-YYYYMMDD-XXXXXXXX)
sanity_idStringNoSanity document ID
paymentUUIDNoForeign key to TransactionPayment
amountDecimalNoRefund amount
reasonStringNoRefund reason
statusStringNoRefund status (succeeded/pending/failed)
processor_refund_idStringNoProcessor's refund ID
processed_atDateTimeYesWhen refund was processed
sales_returnUUIDNoForeign key to Return (optional)
metadataJSONNoAdditional metadata
created_atDateTimeYesCreation timestamp
updated_atDateTimeYesLast update timestamp

Computed Fields
payment_amount

Type: DecimalField (from related object) Read-Only: Yes Source: payment.amount

payment_amount = serializers.DecimalField(
    source='payment.amount',
    max_digits=10,
    decimal_places=2,
    read_only=True
)

Description: Original payment amount for comparison with refund amount.


transaction_id

Type: CharField (from related object) Read-Only: Yes Source: payment.transaction.transactionId

transaction_id = serializers.CharField(source='payment.transaction.transactionId', read_only=True)

Description: The human-readable transaction ID (e.g. TXN-20260708-ABC123), not the UUID primary key, for tracing a refund back to its originating sale.


Example Output
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_id": "REF-20251118-ABC123",
  "sanity_id": "refund-abc123",
  "payment": "payment-uuid",
  "payment_amount": "125.50",
  "transaction_id": "transaction-uuid",
  "amount": "25.00",
  "reason": "Customer returned one item - defective",
  "status": "succeeded",
  "processor_refund_id": "re_3NqXYZ...",
  "processed_at": "2025-11-18T10:15:00Z",
  "sales_return": "return-uuid",
  "metadata": {
    "initiated_by": "MANAGER-12345"
  },
  "created_at": "2025-11-18T10:10:00Z",
  "updated_at": "2025-11-18T10:15:00Z"
}

Simplified Serializers

PaymentMethodSimpleSerializer

Lightweight serializer for nested payment method references.

Source: serializers.py:299-305

Purpose: Minimal payment method data for embedding in other responses.


Fields

class PaymentMethodSimpleSerializer(serializers.ModelSerializer):
    class Meta:
        model = PaymentMethod
        fields = ['id', 'name', 'method_type', 'is_active']

Fields:

  • id: UUID
  • name: Display name
  • method_type: Type (cash, credit_card, etc.)
  • is_active: Active status

Use Case: Embedded in transaction/payment responses to show payment method without full details.

Example:

{
  "id": "uuid",
  "name": "Visa/Mastercard",
  "method_type": "credit_card",
  "is_active": true
}

PaymentSimpleSerializer

Lightweight serializer for nested payment references.

Source: serializers.py:307-319

Purpose: Minimal payment data for embedding in transaction responses.


Fields

class PaymentSimpleSerializer(serializers.ModelSerializer):
    payment_method_name = serializers.CharField(source='payment_method.name', read_only=True)

    class Meta:
        model = TransactionPayment
        fields = [
            'id', 'amount', 'status', 'payment_method', 'payment_method_name',
            'processing_fee', 'net_amount', 'created_at'
        ]
        read_only_fields = ['payment_method_name']

Fields:

  • id: UUID
  • amount: Payment amount
  • status: Payment status
  • payment_method: Foreign key UUID
  • payment_method_name: Denormalized name (read-only)
  • processing_fee: Processor fee
  • net_amount: Net after fees
  • created_at: Timestamp

Use Case: Embedded in transaction responses to show payments without nested refunds.

Example:

{
  "id": "uuid",
  "amount": "125.50",
  "status": "succeeded",
  "payment_method": "payment-method-uuid",
  "payment_method_name": "Visa/Mastercard",
  "processing_fee": "3.64",
  "net_amount": "121.86",
  "created_at": "2025-11-17T14:30:00Z"
}

Create Serializers

PaymentProcessorCreateSerializer

Validation-focused serializer for creating payment processors.

Source: serializers.py:321-337

Purpose: Enforces validation rules for processor creation via API.


Fields

class PaymentProcessorCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = PaymentProcessor
        fields = [
            'name', 'processor_type', 'is_active', 'is_test_mode',
            'configuration', 'processing_fee_percentage', 'processing_fee_fixed',
            'default_currency'
        ]

Excluded Read-Only Fields: id, sanity_id, created_at, updated_at


Validation

validate_default_currency(value)

Source: serializers.py:332-336

def validate_default_currency(self, value):
    """Validate currency code format."""
    if value and len(value) != 3:
        raise serializers.ValidationError('Currency code must be exactly 3 characters')
    return value.upper() if value else value

Rules:

  1. Must be exactly 3 characters
  2. Auto-uppercase for consistency

Examples:

  • "usd""USD"
  • "EUR""EUR"
  • "US" → ValidationError
  • "EURO" → ValidationError

PaymentMethodCreateSerializer

Validation-focused serializer for creating payment methods.

Source: serializers.py:339-359

Purpose: Enforces cross-field validation for payment method creation.


Fields

class PaymentMethodCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = PaymentMethod
        fields = [
            'name', 'method_type', 'processor', 'is_active',
            'is_online_available', 'is_pos_available', 'minimum_amount',
            'maximum_amount', 'configuration', 'display_order',
            'icon_url', 'description'
        ]

Excluded Read-Only Fields: id, sanity_id, created_at, updated_at


Validation

validate(data)

Source: serializers.py:351-358

def validate(self, data):
    """Validate payment method data."""
    if data.get('maximum_amount') and data.get('minimum_amount'):
        if data['minimum_amount'] > data['maximum_amount']:
            raise serializers.ValidationError(
                'Minimum amount cannot be greater than maximum amount'
            )
    return data

Rules:

  1. If both minimum_amount and maximum_amount are provided, minimum_amount must be ≤ maximum_amount

Examples:

  • min: 1.00, max: 10000.00
  • min: 1.00, max: null → (no max)
  • min: 100.00, max: 50.00 → ValidationError

PaymentCreateSerializer

Validation-focused serializer for creating payments.

Source: serializers.py:361-383

Purpose: Enforces validation rules for payment creation via API.


Fields

class PaymentCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = TransactionPayment
        fields = [
            'transaction', 'payment_method', 'amount', 'status',
            'processing_fee', 'processor_transaction_id', 'processor_response',
            'risk_score', 'cash_drawer_event', 'processed_at', 'metadata'
        ]

Excluded Read-Only Fields: id, sanity_id, net_amount, change_due, created_at, updated_at


Validation

validate_amount(value)

Source: serializers.py:372-376

def validate_amount(self, value):
    """Validate payment amount."""
    if value <= 0:
        raise serializers.ValidationError('Payment amount must be greater than 0')
    return value

Rules:

  1. Amount must be > 0

Examples:

  • 100.00
  • 0.01
  • 0.00 → ValidationError
  • -10.00 → ValidationError

validate_risk_score(value)

Source: serializers.py:378-382

def validate_risk_score(self, value):
    """Validate risk score range."""
    if value is not None and (value < 0 or value > 100):
        raise serializers.ValidationError('Risk score must be between 0 and 100')
    return value

Rules:

  1. If provided, must be between 0 and 100 (inclusive)
  2. None is allowed (no risk score)

Examples:

  • 50.00
  • 0.00
  • 100.00
  • null
  • 150.00 → ValidationError
  • -5.00 → ValidationError

RefundCreateSerializer

Validation-focused serializer for creating refunds.

Source: serializers.py:385-406

Purpose: Enforces validation rules including payment amount limits.


Fields

class RefundCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Refund
        fields = [
            'payment', 'amount', 'reason', 'status',
            'processor_refund_id', 'processed_at', 'metadata'
        ]

Excluded Read-Only Fields: id, refund_id, sanity_id, created_at, updated_at


Validation

validate(data)

Source: serializers.py:395-405

def validate(self, data):
    """Validate refund data."""
    if data['amount'] <= 0:
        raise serializers.ValidationError('Refund amount must be greater than 0')

    if data.get('payment'):
        if data['amount'] > data['payment'].amount:
            raise serializers.ValidationError(
                'Refund amount cannot exceed original payment amount'
            )

    return data

Rules:

  1. Refund amount must be > 0
  2. Refund amount cannot exceed original payment.amount

Examples:

  • Original payment: $100, refund: $50 →
  • Original payment: $100, refund: $100 → (full refund)
  • Original payment: $100, refund: $150 → ValidationError
  • Any payment, refund: $0 → ValidationError

Field Transformation Patterns

camelCase → snake_case Conversion

Pattern: All serializers use bidirectional_field_mapper for automatic field name conversion.

Implementation:

from ..integrations.sync.field_mappings import bidirectional_field_mapper

def to_internal_value(self, data):
    converted_data = {}
    for key, value in data.items():
        if key in ['_id', '_type', '_rev', '_createdAt', '_updatedAt']:
            continue  # Skip Sanity metadata

        django_field = bidirectional_field_mapper.get_django_field_name(key, 'payments')
        converted_data[django_field] = value

    return super().to_internal_value(converted_data)

Example Conversions:

Sanity (camelCase)Django (snake_case)
processorTypeprocessor_type
isActiveis_active
isTestModeis_test_mode
processingFeePercentageprocessing_fee_percentage
processingFeeFixedprocessing_fee_fixed
defaultCurrencydefault_currency
paymentMethodpayment_method
tenderAmounttender_amount
changeDuechange_due
processingFeeprocessing_fee
netAmountnet_amount

Ignored Fields: _id, _type, _rev, _createdAt, _updatedAt (Sanity system fields)


Reference Handling

Sanity Reference Pattern

Sanity sends foreign keys as reference objects:

{
  "processor": {
    "_ref": "processor-stripe-prod",
    "_type": "reference"
  }
}

Django Resolution Pattern

Serializers resolve references to Django foreign keys:

processor_ref = converted_data.get('processor')
if isinstance(processor_ref, dict) and '_ref' in processor_ref:
    PaymentProcessor = apps.get_model('payments', 'PaymentProcessor')
    processor_instance = PaymentProcessor.objects.filter(sanity_id=processor_ref['_ref']).first()
    converted_data['processor'] = processor_instance.pk if processor_instance else None

Resolution Steps:

  1. Check if value is dict with _ref key
  2. Get model using apps.get_model()
  3. Query by sanity_id field
  4. Convert to Django primary key (UUID) or None if not found

Graceful Failure: If reference cannot be resolved, sets field to None instead of raising exception. This allows webhook processing to continue even if referenced document hasn't synced yet.


Array of References Pattern

For many-to-many relationships, Sanity sends array of references:

{
  "availableAtStores": [
    {
      "_key": "key1",
      "_ref": "store-001",
      "_type": "reference"
    },
    {
      "_key": "key2",
      "_ref": "store-002",
      "_type": "reference"
    }
  ]
}

Django Processing:

def _process_available_stores(self, payment_method, stores_data):
    # Clear existing associations
    payment_method.available_at_stores.all().delete()

    # Create new associations
    for store_ref in stores_data:
        if isinstance(store_ref, dict) and '_ref' in store_ref:
            store_instance = Store.objects.filter(sanity_id=store_ref['_ref']).first()
            if store_instance:
                PaymentMethodStore.objects.create(
                    payment_method=payment_method,
                    store=store_instance
                )

Idempotency: Always clears and recreates to match Sanity state exactly.


Was this page helpful?