Payments Domain - Services Documentation

Complete business logic documentation for the Payments domain service layer.

Source: api/nextango/apps/payments/services.py

Table of Contents


Overview

The Payments services module implements business logic for payment processing, refunds, processor integration, and payment method management. Services encapsulate complex workflows and ensure consistent transaction handling across the platform.

Architecture

Service Layer Pattern: Views call services → Services execute business logic → Services interact with models

Benefits:

  • Reusability: Business logic can be called from views, signals, webhooks, or management commands
  • Testability: Service methods can be unit tested without HTTP layer
  • Transaction Safety: All state-changing operations wrapped in @transaction.atomic
  • Separation of Concerns: Views handle HTTP, services handle business logic

Key Components

  • Service classes: PaymentProcessorService, PaymentService, RefundService, PaymentMethodService
  • Multi-processor support: Stripe, Square, PayPal (Zettle), Custom Bank, routed through the gateway abstraction
  • Partial payment support: Split transactions across multiple payment methods
  • POS integration: String-based payment type resolution for legacy POS systems

Critical Security Pattern

Credentials Never Stored in Database:

All service methods load processor credentials from Django settings (environment variables or Docker secrets), NEVER from database configuration fields.

Example (services.py:49-50):

# CORRECT: Credentials from settings
api_key = settings.STRIPE_API_KEY

# FORBIDDEN: Credentials from database
# api_key = processor.configuration.get('api_key')  # NEVER DO THIS

Why: Database credentials create security vulnerabilities (SQL injection exposure, accidental commit to version control, audit trail leaks). Environment variables follow 12-factor app principles.


Payment Gateway Layer

The gateway layer is the authoritative abstraction for all external payment processor communication. It was introduced to replace ad-hoc Stripe-specific calls scattered across views, webhooks, and services with a clean, processor-agnostic interface.

BasePaymentGateway (Abstract)

File: api/nextango/apps/payments/gateways/base.py

All processor implementations subclass BasePaymentGateway and must implement all 5 abstract methods. Views and services call get_gateway_for_processor() and never reference a specific processor class directly.

MethodSignaturePurpose
get_connection_token(location_id=None) → DictTerminal SDK auth token; {secret, processor_type}
create_payment_session(amount_cents, currency, metadata=None) → DictCreate payment session/intent; {id, client_secret, amount, currency, status}
verify_webhook(raw_body: bytes, signature_header: str) → DictValidate webhook signature, return parsed event dict
process_refund(processor_transaction_id, amount, metadata=None) → DictIssue refund; {success, refund_id, status, message}
test_connection() → DictValidate credentials without processing a payment; {success, message}

Custom exceptions (also from base.py):

  • WebhookVerificationError, raised when a webhook signature cannot be verified
  • GatewayConnectionError, raised when a gateway cannot be reached or has no registered implementation

Credentials contract: Subclasses read credentials from Django settings (environment variables / Docker secrets), never from the PaymentProcessor DB record. The processor argument is provided for metadata only (is_test_mode, processor_type, etc.).

GatewayRegistry

File: api/nextango/apps/payments/gateways/registry.py

The registry maps processor_type string → gateway class and instantiates the correct implementation at runtime.

from nextango.apps.payments.gateways.registry import get_gateway_for_processor

gateway = get_gateway_for_processor(processor)   # processor.processor_type = "stripe" | "custom" | "square" | "paypal"
result  = gateway.get_connection_token()

get_gateway_for_processor(processor) raises GatewayConnectionError if no gateway is registered for processor.processor_type.

Implementations

GatewayFileStatus
StripeGatewaygateways/stripe_gateway.pyLive in production. Stripe Terminal, PaymentIntents, HMAC webhook verification, refunds
SquareGatewaygateways/square_gateway.pyFull implementation, all 5 gateway methods. Activates on SQUARE_ACCESS_TOKEN + related env vars
PayPalGatewaygateways/paypal_gateway.pyFull implementation (Zettle), all 5 gateway methods. Activates on PAYPAL_ZETTLE_* env vars
CustomBankGatewaygateways/custom_bank_gateway.pyFull implementation, all 5 gateway methods. Activates on BANK_GATEWAY_* env vars

All four gateways have complete implementations. Square, PayPal, and Custom Bank need live credentials configured before they contact real external APIs, without credentials their calls raise GatewayConnectionError, not NotImplementedError. MOCK_PAYMENT_PROCESSORS=True switches all four gateways, Stripe included, to deterministic mock responses: StripeGateway gates get_connection_token, create_payment_session, process_refund, charge_card_not_present, and test_connection on the setting (test_connection also mocks when DEBUG is on). The setting is used in tests and local development; Stripe additionally has its own sandbox mode via a test-mode STRIPE_API_KEY when the mock flag is off.

PayPal (Zettle) is device-initiated: create_payment_session() returns a UUID reference rather than a client secret, the POS terminal itself confirms the charge. PayPalGateway.verify_webhook() performs full RSA-SHA256 certificate-based verification per the PayPal v1 webhook scheme: it requires the five PayPal v1 headers, enforces PayPal-Auth-Algo == 'SHA256withRSA', validates and fetches the signing certificate from PayPal-Cert-Url (HTTPS with a .paypal.com hostname, cached for 60 minutes), and verifies the signature over the canonical message {transmission_id}|{transmission_time}|{webhook_id}|{crc32(body)}. A parse-only passthrough happens only in mock mode.


The idempotency mixin

File: api/nextango/apps/payments/mixins/idempotency.py

IdempotencyMixin applies DRF-view-level deduplication backed by IdempotencyRecord (a transactions app model). A view opts in by including the mixin and declaring IDEMPOTENT_ACTIONS, a list of ViewSet action names or lowercase HTTP methods.

On initial(), the mixin first calls authorize_before_idempotency(request), a no-op hook by default, so resource-ownership checks run before any idempotency lookup or short-circuit. Views that need per-session scoping (for example StripeConfirmPaymentView) override this hook, verify the caller owns the referenced resource, and stash the resolved session on request._authorized_web_session. The idempotency key is then derived scoped to that session (ws:{session_id}:{raw_key}), falling back to the authenticated user id or client IP when no session is stashed.

Lookup logic: a new key claims an IdempotencyRecord row with status='processing'. A concurrent request with the same key while the first is still processing gets 409 IDEMPOTENCY_KEY_BUSY with a Retry-After header. The same key submitted with a different request body gets 409 IDEMPOTENCY_KEY_BODY_MISMATCH. Once the original request completes, its terminal response (2xx or 4xx, not 5xx or 409) is cached on the record with status='completed', and replays of the same key return that cached response directly without re-executing the view.


Service Classes

PaymentProcessorService

Source: services.py:14-128

Service for testing payment processor API connections.

Methods

test_processor_connection(processor)

Source: services.py:17-42

Purpose: Test connection to payment processor API to verify configuration and credentials.

Parameters:

  • processor (PaymentProcessor): Processor to test

Returns: Dict with connection test results

Validation (services.py:20-24):

if not processor.is_active:
    return {
        'success': False,
        'message': 'Processor is not active'
    }

Processor Routing (services.py:28-37):

processor_handlers = {
    'stripe': PaymentProcessorService._test_stripe_connection,
    'square': PaymentProcessorService._test_square_connection,
    'paypal': PaymentProcessorService._test_paypal_connection,
    'custom': PaymentProcessorService._test_custom_connection,
}

handler = processor_handlers.get(processor.processor_type)
if handler:
    return handler(processor)

Pattern: Strategy pattern - routes to processor-specific handler based on processor_type.

Example Usage:

from nextango.apps.payments.services import PaymentProcessorService

processor = PaymentProcessor.objects.get(sanity_id='processor-stripe-prod')
result = PaymentProcessorService.test_processor_connection(processor)

if result['success']:
    print(f"{result['message']}")
else:
    print(f"{result['message']}")

Success Response:

{
    'success': True,
    'message': 'Stripe connection successful',
    'test_mode': False
}

Failure Response:

{
    'success': False,
    'message': 'Stripe API key not configured in environment'
}

Use Case: Admin dashboard "Test Connection" button for validating processor configuration.


_test_stripe_connection(processor)

Source: services.py:44-63

Purpose: Test Stripe API connection using credentials from Django settings.

Security Pattern (services.py:49-50):

# Credentials from settings (environment variables)
api_key = settings.STRIPE_API_KEY

Validation (services.py:52-56):

if not api_key:
    return {
        'success': False,
        'message': 'Stripe API key not configured in environment'
    }

Implementation Status: TODO - Line 58 indicates actual Stripe API test not implemented.

Current Behavior (mock):

return {
    'success': True,
    'message': 'Stripe connection test (mock - credentials loaded from settings)',
    'test_mode': processor.is_test_mode
}

Future Implementation Should:

import stripe

stripe.api_key = api_key

try:
    # Test API call - list payment methods or retrieve account
    account = stripe.Account.retrieve()
    return {
        'success': True,
        'message': f'Connected to Stripe account: {account.business_profile.name}',
        'test_mode': processor.is_test_mode,
        'account_id': account.id
    }
except stripe.error.AuthenticationError:
    return {
        'success': False,
        'message': 'Invalid Stripe API key'
    }
except stripe.error.StripeError as e:
    return {
        'success': False,
        'message': f'Stripe error: {str(e)}'
    }

_test_square_connection(processor)

Source: services.py:65-84

Purpose: Test Square API connection using credentials from Django settings.

Security Pattern (services.py:70-71):

access_token = settings.SQUARE_ACCESS_TOKEN

Implementation Status: TODO - Mock implementation, actual Square API test not implemented.

Future Implementation Should:

  • Use Square Python SDK
  • Make test API call (e.g., list locations)
  • Verify access token validity
  • Return location information

_test_paypal_connection(processor)

Source: services.py:86-106

Purpose: Test PayPal API connection using credentials from Django settings.

Security Pattern (services.py:92-93):

client_id = settings.PAYPAL_CLIENT_ID
client_secret = settings.PAYPAL_CLIENT_SECRET

Implementation Status: TODO - Mock implementation, actual PayPal API test not implemented.

Future Implementation Should:

  • Use PayPal REST SDK
  • Authenticate with OAuth2 token
  • Make test API call
  • Verify credentials valid for current environment (sandbox vs live)

_test_custom_connection(processor)

Source: services.py:108-127

Purpose: Test custom payment processor API connection.

Security Pattern (services.py:114):

api_key = settings.CUSTOM_PROCESSOR_API_KEY

Implementation Status: TODO - Placeholder for custom processor integration.

Note (services.py:606):

# processor.configuration can still be used for non-sensitive config like webhook URLs

Pattern: Database configuration field stores non-sensitive settings (webhook URLs, timeout values), environment variables store credentials.


PaymentService

Source: services.py:130-612

Service for payment processing operations - the core payment business logic.

Methods

calculate_processing_fee(amount, payment_method)

Source: services.py:133-150

Purpose: Calculate processing fee for a payment based on processor fee structure.

Parameters:

  • amount (Decimal): Payment amount
  • payment_method (PaymentMethod): Payment method with processor configuration

Returns: Decimal processing fee (2 decimal places)

Fee Calculation Logic (services.py:136-149):

if not payment_method.processor:
    return Decimal('0.00')  # No processor = no fee

processor = payment_method.processor
fee = Decimal('0.00')

# Add percentage fee
if processor.processing_fee_percentage:
    fee += amount * (processor.processing_fee_percentage / Decimal('100'))

# Add fixed fee
if processor.processing_fee_fixed:
    fee += processor.processing_fee_fixed

return fee.quantize(Decimal('0.01'))  # Always 2 decimal places

Example Calculation:

Stripe typical fees: 2.9% + $0.30

processor.processing_fee_percentage = Decimal('2.90')
processor.processing_fee_fixed = Decimal('0.30')

# Payment of $100.00
fee = calculate_processing_fee(Decimal('100.00'), payment_method)
# fee = (100.00 * 0.029) + 0.30 = 2.90 + 0.30 = $3.20

Use Cases:

  • Calculate fees before creating payment
  • Display fees to customer during checkout
  • Financial reporting - net amount after fees

apply_payment_to_transaction(transaction_id, payment_method_id, tendered_amount, metadata=None)

Source: services.py:152-321

Purpose: Apply a payment to a transaction, supporting partial payments and split payment scenarios.

Primary Payment Method: This is the main method used by POS systems for applying payments.

Parameters:

  • transaction_id (str): Transaction ID field (e.g., "TXN-20251117-ABC123"), NOT primary key UUID
  • payment_method_id (str): Payment method UUID
  • tendered_amount (Decimal): Amount customer provided (important for cash change calculation)
  • metadata (Dict, optional): Additional payment metadata

Returns: Dict with success status, payment details, and transaction state

Transaction Atomic Wrapper (services.py:175):

with transaction.atomic():
    # All operations succeed or all fail

Business Logic Flow:

1. Fetch Transaction by transactionId (services.py:177-180):

SaleTransaction = apps.get_model('transactions', 'SaleTransaction')
sale_transaction = SaleTransaction.objects.get(transactionId=transaction_id)

Important: Uses transactionId field (human-readable like "TXN-20251117-ABC123"), NOT id (UUID primary key).

2. Validate Transaction State (services.py:182-191):

if sale_transaction.status in ['completed', 'voided', 'refunded']:
    return {
        'success': False,
        'error': f'Cannot apply payment to {sale_transaction.status} transaction',
        'transaction': {
            'transactionId': sale_transaction.transactionId,
            'status': sale_transaction.status
        }
    }

Forbidden States: Cannot add payments to completed, voided, or refunded transactions.

3. Validate Minimum Amount (services.py:194-205):

payment_method = PaymentMethod.objects.get(id=payment_method_id)

if tendered_amount < payment_method.minimum_amount:
    return {
        'success': False,
        'error': f'Amount below minimum of \${payment_method.minimum_amount}'
    }

4. Calculate Payment Amount (Partial Payment Logic) (services.py:207-212):

remaining_balance = sale_transaction.payment_balance

# Payment amount is capped at remaining balance, but never negative
# If balance is negative (overpaid), payment_amount is 0
payment_amount = max(Decimal('0.00'), min(tendered_amount, remaining_balance))

Example Scenarios:

Scenario 1: Exact Payment

  • Transaction total: $100.00
  • Payment balance: $100.00
  • Tendered: $100.00
  • Payment amount: $100.00 (min($100, $100) = $100)
  • Result: Transaction completed

Scenario 2: Partial Payment

  • Transaction total: $100.00
  • Payment balance: $100.00
  • Tendered: $60.00
  • Payment amount: $60.00 (min($60, $100) = $60)
  • New balance: $40.00
  • Result: Transaction still pending, needs another $40

Scenario 3: Over-payment (Cash)

  • Transaction total: $100.00
  • Payment balance: $100.00
  • Tendered: $120.00 (customer gave $120 cash)
  • Payment amount: $100.00 (min($120, $100) = $100)
  • Change due: $20.00
  • Result: Transaction completed, $20 change returned

Scenario 4: Split Payment - Second Payment

  • Transaction total: $100.00
  • Payment balance: $40.00 (after first $60 payment)
  • Tendered: $40.00
  • Payment amount: $40.00 (min($40, $40) = $40)
  • Result: Transaction completed

5. Calculate Change Due (Cash Only) (services.py:217-220):

change_due = None
if payment_method.method_type == 'cash' and tendered_amount > payment_amount:
    change_due = tendered_amount - payment_amount

Why Cash Only: Credit card transactions don't have "change" - they charge exact amount. Only cash has physical change returned.

6. Metadata Sanitization (services.py:231-244):

clean_metadata = {}
if metadata:
    for key, value in metadata.items():
        if key == 'change_due':
            # Skip change_due from metadata - we calculate it ourselves
            continue
        clean_metadata[key] = value

# Only add change_due to metadata if there is actual change (positive value)
# Must be string for Sanity schema compatibility
if change_due and change_due > 0:
    clean_metadata['change_due'] = str(change_due.quantize(Decimal('0.01')))

Why: Sanity schema expects change_due in metadata as string. Service calculates authoritative value, ignores any client-provided value (security).

7. Create Payment Record (services.py:246-257):

payment = TransactionPayment.objects.create(
    transaction=sale_transaction,
    payment_method=payment_method,
    amount=payment_amount,
    tender_amount=tendered_amount if payment_method.method_type == 'cash' else None,
    change_due=change_due,
    processing_fee=processing_fee,
    status='succeeded',  # POS terminal already confirmed
    processed_at=timezone.now(),
    metadata=clean_metadata
)

Pattern:

  • amount: Actual payment applied to balance
  • tender_amount: Amount customer provided (cash only)
  • change_due: Calculated change (cash only)
  • status='succeeded': POS payments are pre-authorized, immediately succeeded

8. Cash Drawer Integration (services.py:259-265):

if payment_method.method_type == 'cash':
    cash_drawer_event = PaymentService._create_cash_drawer_event(
        sale_transaction, payment_amount, metadata or {}
    )
    payment.cash_drawer_event = cash_drawer_event
    payment.save(update_fields=['cash_drawer_event'])

Automatic: Cash payments create CashDrawerEvent for tracking physical cash flow.

9. Transaction Completion Check (services.py:267-280):

# Refresh from DB to get updated payment_balance
sale_transaction.refresh_from_db()

if sale_transaction.is_fully_paid:
    sale_transaction.status = 'completed'
    sale_transaction.completedAt = timezone.now()
    sale_transaction.save(update_fields=['status', 'completedAt'])
    logger.info(f"Transaction {transaction_id} marked as completed - fully paid")
else:
    logger.info(
        f"Transaction {transaction_id} remains pending - "
        f"balance \${sale_transaction.payment_balance}"
    )

Auto-completion: If payment brings balance to $0, transaction auto-completes.

Partial Payment: If balance remains, transaction stays 'pending', ready for next payment.

10. Return Response (services.py:282-300):

return {
    'success': True,
    'payment': {
        'id': payment.id,
        'amount': str(payment.amount),
        'status': payment.status,
        'method': payment_method.name,
        'method_type': payment_method.method_type
    },
    'transaction': {
        'transactionId': sale_transaction.transactionId,
        'status': sale_transaction.status,
        'totalAmount': str(sale_transaction.totalAmount),
        'payment_balance': str(sale_transaction.payment_balance),
        'is_fully_paid': sale_transaction.is_fully_paid,
        'requires_additional_payment': not sale_transaction.is_fully_paid
    }
}

Error Handling (services.py:302-321):

Transaction Not Found (404):

except SaleTransaction.DoesNotExist:
    return {
        'success': False,
        'error': f'Transaction {transaction_id} not found'
    }

Payment Method Not Found (404):

except PaymentMethod.DoesNotExist:
    return {
        'success': False,
        'error': 'Payment method not found'
    }

Generic Error (500):

except Exception as e:
    logger.error(f"Error applying payment to transaction {transaction_id}: {e}")
    import traceback
    logger.error(traceback.format_exc())
    return {
        'success': False,
        'error': str(e)
    }

Example Usage:

Single Cash Payment:

from nextango.apps.payments.services import PaymentService
from decimal import Decimal

result = PaymentService.apply_payment_to_transaction(
    transaction_id="TXN-20251117-ABC123",
    payment_method_id="cash-payment-method-uuid",
    tendered_amount=Decimal("120.00"),
    metadata={
        "register_id": "POS-01",
        "employee_id": "emp-uuid"
    }
)

if result['success']:
    print(f"Payment applied: \${result['payment']['amount']}")
    print(f"Transaction status: {result['transaction']['status']}")
    if result['transaction']['requires_additional_payment']:
        print(f"Balance remaining: \${result['transaction']['payment_balance']}")

Split Payment Sequence:

# Transaction total: $100.00

# First payment - Cash $60
result1 = PaymentService.apply_payment_to_transaction(
    transaction_id="TXN-20251117-ABC123",
    payment_method_id="cash-uuid",
    tendered_amount=Decimal("60.00")
)
# result1['transaction']['payment_balance'] = "40.00"
# result1['transaction']['status'] = "pending"

# Second payment - Credit card $40
result2 = PaymentService.apply_payment_to_transaction(
    transaction_id="TXN-20251117-ABC123",
    payment_method_id="credit-card-uuid",
    tendered_amount=Decimal("40.00")
)
# result2['transaction']['payment_balance'] = "0.00"
# result2['transaction']['status'] = "completed"

process_payment(transaction_id, payment_method_id, amount, metadata=None)

Source: services.py:323-405

Purpose: Legacy payment processing method - creates payment and processes with processor.

Deprecated Pattern: This method is being replaced by apply_payment_to_transaction() for POS operations. Kept for backward compatibility.

Difference from apply_payment_to_transaction():

  • Uses UUID transaction_id (not human-readable transactionId string)
  • Doesn't support partial payments
  • Always creates 'pending' payments (waits for processor)
  • Returns Tuple instead of Dict

Parameters:

  • transaction_id (int/UUID): Transaction primary key UUID
  • payment_method_id (int/UUID): Payment method primary key UUID
  • amount (Decimal): Payment amount
  • metadata (Dict, optional): Payment metadata

Returns: Tuple[bool, TransactionPayment, str] - (success, payment_object, message)

Dev Mode Auto-Success (services.py:366-377):

from django.conf import settings
if getattr(settings, 'DEBUG', False) and not payment_method.processor:
    payment.status = 'succeeded'
    payment.processor_response = {
        'message': 'Dev mode auto-success',
        'transaction_id': f'dev_mock_{payment.id}',
        'processor': 'dev_mode'
    }
    payment.processor_transaction_id = f'dev_mock_{payment.id}'
    payment.save()
    return True, payment, 'Payment processed successfully (dev mode)'

Why: Allows POS development without configuring real payment processors.

Stripe Webhook Pattern (services.py:384-390):

if payment_method.processor and payment_method.processor.processor_type == 'stripe':
    payment.status = 'pending'  # Wait for webhook confirmation
    logger.info(f"Stripe PaymentIntent created - waiting for webhook confirmation: {processor_response.get('transaction_id')}")
else:
    payment.status = 'succeeded'

Critical Pattern: Stripe payments stay 'pending' after PaymentIntent creation. Webhook handler updates to 'succeeded' when Stripe confirms payment.

Other Processors: Marked 'succeeded' immediately since they don't have webhooks configured.

Example Usage:

success, payment, message = PaymentService.process_payment(
    transaction_id=transaction_uuid,
    payment_method_id=payment_method_uuid,
    amount=Decimal("100.00"),
    metadata={'device_id': 'POS-01'}
)

if success:
    print(f"{message}")
    print(f"Payment ID: {payment.id}, Status: {payment.status}")
else:
    print(f"{message}")

process_payment_with_pos_type(transaction_id, payment_type, amount, store_id=None, metadata=None)

Source: services.py:407-441

Purpose: Process payment using POS legacy string types ('cash', 'card', 'check') instead of PaymentMethod UUIDs.

Use Case: Legacy POS systems that send hardcoded strings like "cash" or "card" instead of database IDs.

Parameters:

  • transaction_id (int): Transaction UUID
  • payment_type (str): POS string ('cash', 'card', 'check', 'credit', 'debit')
  • amount (Decimal): Payment amount
  • store_id (int, optional): Store UUID for store-specific method resolution
  • metadata (Dict, optional): Payment metadata

Returns: Tuple[bool, TransactionPayment, str] - (success, payment_object, message)

Resolution Logic (services.py:420-429):

# Resolve POS payment type to PaymentMethod
payment_method = PaymentMethodService.resolve_pos_payment_type(payment_type, store_id)

if not payment_method:
    # Try to create default methods in dev mode
    default_methods = PaymentMethodService.get_or_create_default_payment_methods(store_id)
    payment_method = default_methods.get(payment_type.lower())

    if not payment_method:
        return False, None, f"Payment method '{payment_type}' not available"

Fallback: In dev mode, automatically creates default payment methods if they don't exist.

Example Usage:

# Legacy POS sends "cash" string
success, payment, message = PaymentService.process_payment_with_pos_type(
    transaction_id=transaction_uuid,
    payment_type="cash",  # String instead of UUID
    amount=Decimal("50.00"),
    store_id=store_uuid
)

_process_with_processor(payment, processor)

Source: services.py:443-461

Purpose: Route payment to processor-specific handler.

Strategy Pattern (services.py:450-459):

processor_handlers = {
    'stripe': PaymentService._process_stripe_payment,
    'square': PaymentService._process_square_payment,
    'paypal': PaymentService._process_paypal_payment,
    'custom': PaymentService._process_custom_payment,
}

handler = processor_handlers.get(processor.processor_type)
if handler:
    return handler(payment, processor)

return False, {'message': f'No handler for processor type: {processor.processor_type}'}

Returns: Tuple[bool, Dict] - (success, processor_response)


_create_cash_drawer_event(sales_transaction, amount, metadata)

Source: services.py:464-487

Purpose: Create CashDrawerEvent record for cash payments.

Auto-generated Event ID (services.py:472):

import uuid
from django.utils import timezone

event_id = f"CASH-{timezone.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
# Example: "CASH-20251117-A3F8B2C1"

Event Creation (services.py:474-487):

CashDrawerEvent = apps.get_model('transactions', 'CashDrawerEvent')

return CashDrawerEvent.objects.create(
    eventId=event_id,
    eventType='sale',
    store=sales_transaction.store,
    employee=sales_transaction.employee,
    registerId=metadata.get('register_id', 'POS-01'),
    amount=amount,
    transaction=sales_transaction,
    cashBreakdown=metadata.get('cash_breakdown', {}),
    drawerTotalBefore=metadata.get('drawer_total_before', Decimal('0.00')),
    drawerTotalAfter=metadata.get('drawer_total_after', Decimal('0.00')),
    notes=metadata.get('notes', ''),
    shiftId=metadata.get('shift_id', ''),
)

Metadata Fields:

  • register_id: POS terminal ID
  • cash_breakdown: Denominations (e.g., {"twenties": 5, "tens": 3})
  • drawer_total_before: Cash drawer balance before sale
  • drawer_total_after: Cash drawer balance after sale
  • notes: Additional notes
  • shift_id: Employee shift identifier

Use Case: Track every cash transaction for cash drawer reconciliation at end of shift.


_process_stripe_payment(payment, processor)

Source: services.py:489-542

Purpose: Process payment with Stripe PaymentIntent API.

Security (services.py:495-496):

# Credentials from settings, NOT database
api_key = settings.STRIPE_API_KEY

Credential Validation (services.py:498-502):

if not api_key:
    return False, {
        'message': 'Stripe API key not configured in environment',
        'processor': 'stripe'
    }

Stripe PaymentIntent Creation (services.py:504-528):

import stripe

stripe.api_key = api_key

# Create PaymentIntent
intent = stripe.PaymentIntent.create(
    amount=int(payment.amount * 100),  # Stripe uses cents
    currency=processor.default_currency.lower() if processor.default_currency else 'usd',
    metadata={
        'payment_id': str(payment.id),
        'transaction_id': str(payment.transaction.id) if payment.transaction else None,
        'customer_id': str(payment.transaction.customer.id) if payment.transaction and payment.transaction.customer else None,
    },
    automatic_payment_methods={'enabled': True},
)

logger.info(f"Created Stripe PaymentIntent: {intent.id} for payment {payment.id}")

return True, {
    'transaction_id': intent.id,  # This gets saved to processor_transaction_id
    'message': 'PaymentIntent created successfully',
    'processor': 'stripe',
    'client_secret': intent.client_secret,  # For frontend confirmation
    'status': intent.status,
}

Critical Fields:

  • amount: Payment amount in cents (multiply by 100)
  • currency: Lowercase ISO code (e.g., 'usd')
  • metadata: Reference IDs for tracking payment back to database
  • automatic_payment_methods: Enable all payment methods configured in Stripe dashboard
  • client_secret: Returned to frontend for confirming payment with Stripe.js

Stripe Workflow:

  1. Backend: Create PaymentIntent (this method)
  2. Backend: Return client_secret to frontend
  3. Frontend: Use client_secret with Stripe.js or Terminal SDK to collect payment
  4. Stripe: Process payment, send webhook
  5. Backend: Webhook handler updates payment status to 'succeeded'

Error Handling (services.py:530-542):

Stripe API Error:

except stripe._error.StripeError as e:
    logger.error(f"Stripe error creating PaymentIntent: {e}")
    return False, {
        'message': f'Stripe error: {str(e)}',
        'processor': 'stripe',
        'error_type': type(e).__name__,
    }

Generic Error:

except Exception as e:
    logger.error(f"Unexpected error creating Stripe PaymentIntent: {e}")
    return False, {
        'message': f'Unexpected error: {str(e)}',
        'processor': 'stripe'
    }

_process_square_payment(payment, processor)

Source: services.py:544-565

Purpose: Process payment with Square API.

Security (services.py:549-551):

access_token = settings.SQUARE_ACCESS_TOKEN
location_id = settings.SQUARE_LOCATION_ID

Implementation Status: TODO - Mock implementation, actual Square API integration not implemented.

Future Implementation Should:

from square.client import Client

client = Client(
    access_token=access_token,
    environment='production' if not processor.is_test_mode else 'sandbox'
)

result = client.payments.create_payment(
    body={
        "source_id": "...",  # Card token from Square SDK
        "idempotency_key": str(payment.id),
        "amount_money": {
            "amount": int(payment.amount * 100),  # Square uses cents
            "currency": processor.default_currency or "USD"
        },
        "location_id": location_id
    }
)

return True, {
    'transaction_id': result.body['payment']['id'],
    'message': 'Square payment processed',
    'processor': 'square'
}

_process_paypal_payment(payment, processor)

Source: services.py:567-588

Purpose: Process payment with PayPal API.

Security (services.py:572-574):

client_id = settings.PAYPAL_CLIENT_ID
client_secret = settings.PAYPAL_CLIENT_SECRET

Implementation Status: TODO - Mock implementation.


_process_custom_payment(payment, processor)

Source: services.py:590-611

Purpose: Process payment with custom processor.

Security (services.py:595-597):

api_key = settings.CUSTOM_PROCESSOR_API_KEY
api_secret = settings.CUSTOM_PROCESSOR_API_SECRET

Configuration Note (services.py:606):

# processor.configuration can still be used for non-sensitive config like webhook URLs

Pattern: Environment variables for credentials, database for non-sensitive configuration (webhook URLs, timeout values, display preferences).


RefundService

Source: services.py:614-756

Service for processing refunds.

Methods

process_refund(payment, amount, reason='', metadata=None, sales_return=None)

Source: services.py:617-686

Signature: process_refund(payment_id, amount, reason='', metadata=None, sales_return=None) → Tuple[bool, Optional[Refund], str]

Purpose: Process a refund for a payment.

Parameters:

  • payment_id (int/UUID): TransactionPayment UUID to refund
  • amount (Decimal): Refund amount
  • reason (str, optional): Refund reason
  • metadata (Dict, optional): Additional metadata
  • sales_return (Return, optional): Return instance to link refund to

Returns: Tuple[bool, Optional[Refund], str], always a 3-tuple; call sites must unpack as three values:

success, refund, message = RefundService.process_refund(...)
Scenariosuccessrefundmessage
SuccessTrueRefund instance (status='succeeded')"Refund processed successfully"
Failure, no payment methodFalsePersisted Refund instance (status='failed')error description
Failure, validation errorFalseNoneerror description
Failure, unexpected exceptionFalseNoneexception string

Important: When payment.payment_method is None, a Refund record is still created and persisted with status='failed' before returning (False, refund, message). The refund object is not None in this case.

Transaction Atomic (services.py:639):

with transaction.atomic():
    # All operations succeed or all fail

Validation (services.py:642-655):

1. Amount Positive:

if amount <= 0:
    return False, None, 'Refund amount must be greater than 0'

2. Amount Not Exceeding Payment:

if amount > payment.amount:
    return False, None, 'Refund amount cannot exceed payment amount'

3. Total Refunds Check (services.py:649-655):

# Check existing refunds
total_refunded = payment.refunds.filter(status='succeeded').aggregate(
    total=models.Sum('amount')
)['total'] or Decimal('0.00')

if (total_refunded + amount) > payment.amount:
    return False, None, 'Total refunds cannot exceed payment amount'

Critical: Only counts status='succeeded' refunds. Failed refunds don't count against total.

Refund Creation (services.py:657-665):

refund = Refund.objects.create(
    payment=payment,
    amount=amount,
    reason=reason,
    status='pending',
    metadata=metadata or {},
    sales_return=sales_return  # Link to return if provided
)

logger.info(f"Created refund {refund.refund_id} for payment {payment.id}, amount \${amount}")

Auto-generated: refund_id field auto-generated by model's save() method (REF-YYYYMMDD-XXXXXXXX format).

Processor Integration (services.py:669-680):

# Process with payment processor
success, processor_response = RefundService._process_with_processor(
    refund, payment.payment_method.processor
)

if success:
    refund.status = 'succeeded'
    refund.processor_refund_id = processor_response.get('refund_id')
else:
    refund.status = 'failed'

refund.save()

Pattern: Immediately process with processor. Status set based on processor response.

Example Usage:

Full Refund:

from nextango.apps.payments.services import RefundService
from decimal import Decimal

success, refund, message = RefundService.process_refund(
    payment_id=payment_uuid,
    amount=Decimal("100.00"),  # Full payment amount
    reason="Customer requested full refund"
)

if success:
    print(f"Refund {refund.refund_id}: {message}")
else:
    print(f"{message}")

Partial Refund:

# Original payment: $100.00
# Refund only $25.00

success, refund, message = RefundService.process_refund(
    payment_id=payment_uuid,
    amount=Decimal("25.00"),  # Partial amount
    reason="Partial return - 1 item returned"
)

# Payment still has $75.00 that can be refunded in future

Linked to Return:

# When processing return, link refund to return transaction
Return = apps.get_model('transactions', 'Return')
return_instance = Return.objects.get(returnId='RET-20251117-ABC123')

success, refund, message = RefundService.process_refund(
    payment_id=payment_uuid,
    amount=Decimal("50.00"),
    reason="Return processed",
    sales_return=return_instance  # Link refund to return
)

_process_with_processor(refund, processor)

Source: services.py:688-715

Purpose: Route refund to processor-specific handler.

Dev Mode Auto-Success (services.py:691-698):

from django.conf import settings
if getattr(settings, 'DEBUG', False) and not processor:
    return True, {
        'refund_id': f'dev_mock_refund_{refund.id}',
        'message': 'Refund processed successfully (dev mode)',
        'processor': 'dev_mode'
    }

Strategy Pattern (services.py:704-713):

processor_handlers = {
    'stripe': RefundService._process_stripe_refund,
    'square': RefundService._process_square_refund,
    'paypal': RefundService._process_paypal_refund,
    'custom': RefundService._process_custom_refund,
}

handler = processor_handlers.get(processor.processor_type)
if handler:
    return handler(refund, processor)

_process_stripe_refund(refund, processor)

Source: services.py:717-725

Purpose: Process refund with Stripe Refund API.

Implementation Status: TODO - Mock implementation.

Future Implementation Should:

import stripe
from django.conf import settings

stripe.api_key = settings.STRIPE_API_KEY

# Get original payment's processor_transaction_id (PaymentIntent ID)
payment_intent_id = refund.payment.processor_transaction_id

try:
    # Create Stripe Refund
    stripe_refund = stripe.Refund.create(
        payment_intent=payment_intent_id,
        amount=int(refund.amount * 100),  # Stripe uses cents
        metadata={
            'refund_id': str(refund.id),
            'reason': refund.reason
        }
    )

    return True, {
        'refund_id': stripe_refund.id,
        'message': 'Stripe refund processed',
        'processor': 'stripe',
        'status': stripe_refund.status
    }

except stripe.error.StripeError as e:
    return False, {
        'message': f'Stripe error: {str(e)}',
        'processor': 'stripe'
    }

_process_square_refund(refund, processor)

Source: services.py:727-735

Implementation Status: TODO - Mock implementation.


_process_paypal_refund(refund, processor)

Source: services.py:737-745

Implementation Status: TODO - Mock implementation.


_process_custom_refund(refund, processor)

Source: services.py:747-755

Implementation Status: TODO - Mock implementation.


PaymentMethodService

Source: services.py:758-901

Service for payment method management and validation.

Methods

resolve_pos_payment_type(payment_type, store_id=None)

Source: services.py:761-794

Purpose: Resolve POS legacy payment type strings to PaymentMethod objects.

Use Case: Legacy POS systems send hardcoded strings like "cash", "card", "check" instead of database UUIDs.

Parameters:

  • payment_type (str): POS string ('cash', 'card', 'check', 'credit', 'debit')
  • store_id (int, optional): Store UUID for store-specific resolution

Returns: PaymentMethod instance or None

Mapping (services.py:767-774):

pos_type_mapping = {
    'cash': 'cash',
    'card': 'card',
    'check': 'check',
    'credit': 'card',  # Alias for card
    'debit': 'card',   # Alias for card
}

method_type = pos_type_mapping.get(payment_type.lower())

Resolution Logic (services.py:776-794):

1. Try Unknown Type:

if not method_type:
    logger.warning(f"Unknown POS payment type: {payment_type}")
    return None

2. Find Active Method of Type:

query = PaymentMethod.objects.filter(
    method_type=method_type,
    is_active=True
)

3. Prefer Store-Specific Method (services.py:787-791):

if store_id:
    store_specific = query.filter(available_at_stores__store_id=store_id).first()
    if store_specific:
        return store_specific

4. Fallback to Any Active Method:

return query.first()

Example Usage:

# POS sends "cash" string
payment_method = PaymentMethodService.resolve_pos_payment_type(
    payment_type="cash",
    store_id=store_uuid
)

if payment_method:
    print(f"Resolved to: {payment_method.name}")
else:
    print("Payment method not available")

get_or_create_default_payment_methods(store_id=None)

Source: services.py:796-848

Purpose: Get or create default payment methods for POS operations (dev mode only).

Parameters:

  • store_id (int, optional): Store UUID

Returns: Dict[str, PaymentMethod] - Mapping of POS strings to PaymentMethod objects

Default Methods (services.py:805-824):

default_methods = [
    {
        'pos_type': 'cash',
        'method_type': 'cash',
        'name': 'Cash',
        'display_name': 'Cash Payment'
    },
    {
        'pos_type': 'card',
        'method_type': 'card',
        'name': 'Card',
        'display_name': 'Card Payment'
    },
    {
        'pos_type': 'check',
        'method_type': 'check',
        'name': 'Check',
        'display_name': 'Check Payment'
    }
]

Creation Logic (services.py:826-846):

for method_config in default_methods:
    # Try to find existing method
    payment_method = PaymentMethodService.resolve_pos_payment_type(
        method_config['pos_type'], store_id
    )

    # Create if doesn't exist and we're in dev mode
    if not payment_method and getattr(settings, 'DEBUG', False):
        payment_method = PaymentMethod.objects.create(
            name=method_config['name'],
            display_name=method_config['display_name'],
            method_type=method_config['method_type'],
            is_active=True,
            minimum_amount=Decimal('0.01'),
            # No processor for dev mode - will auto-succeed
            processor=None
        )
        logger.info(f"Created default payment method: {payment_method.name}")

    if payment_method:
        methods[method_config['pos_type']] = payment_method

Dev Mode Only (services.py:833):

if not payment_method and getattr(settings, 'DEBUG', False):

Why: Production environments should configure payment methods explicitly. Auto-creation only in development.

No Processor: Dev mode methods have processor=None, which triggers auto-success in process_payment().

Example Usage:

from nextango.apps.payments.services import PaymentMethodService

# Dev mode - auto-create if missing
methods = PaymentMethodService.get_or_create_default_payment_methods()

cash_method = methods.get('cash')
card_method = methods.get('card')
check_method = methods.get('check')

get_available_methods_for_store(store_id)

Source: services.py:850-873

Purpose: Get payment methods available at a specific store.

Parameters:

  • store_id (int/str): Store UUID or sanity_id

Returns: List[PaymentMethod]

Dual Lookup (services.py:855-861):

try:
    # Try lookup by database id first, then by sanity_id
    try:
        store = Store.objects.get(id=store_id)
    except (Store.DoesNotExist, ValueError):
        # If not found by id or invalid UUID, try sanity_id
        store = Store.objects.get(sanity_id=store_id)
except Store.DoesNotExist:
    logger.warning(f"Store not found with id or sanity_id: {store_id}")
    return []

Flexibility: Accepts either UUID primary key OR Sanity string ID.

Availability Logic (services.py:866-871):

# Get methods specifically available at this store OR methods available everywhere
payment_methods = PaymentMethod.objects.filter(
    models.Q(available_at_stores__store=store) |
    models.Q(available_at_stores__isnull=True),  # Empty = available everywhere
    is_active=True
).distinct()

Business Rule: If available_at_stores is empty, method is available at ALL stores.

Example Usage:

available_methods = PaymentMethodService.get_available_methods_for_store(
    store_id="store-downtown-001"  # Sanity ID
)

for method in available_methods:
    print(f"- {method.name} ({method.method_type})")

validate_method_for_transaction(payment_method_id, amount, store_id=None)

Source: services.py:875-901

Purpose: Validate if a payment method can be used for a transaction.

Parameters:

  • payment_method_id (int/UUID): PaymentMethod UUID
  • amount (Decimal): Transaction amount
  • store_id (int, optional): Store UUID

Returns: Tuple[bool, str] - (is_valid, error_message)

Validation Checks:

1. Method Exists (services.py:882-885):

try:
    payment_method = PaymentMethod.objects.get(id=payment_method_id)
except PaymentMethod.DoesNotExist:
    return False, 'Payment method not found'

2. Method Active (services.py:887-888):

if not payment_method.is_active:
    return False, 'Payment method is not active'

3. Minimum Amount (services.py:890-891):

if amount < payment_method.minimum_amount:
    return False, f'Amount below minimum of \${payment_method.minimum_amount}'

4. Maximum Amount (services.py:893-894):

if payment_method.maximum_amount and amount > payment_method.maximum_amount:
    return False, f'Amount exceeds maximum of \${payment_method.maximum_amount}'

5. Store Availability (services.py:896-899):

if store_id:
    available_methods = PaymentMethodService.get_available_methods_for_store(store_id)
    if payment_method not in available_methods:
        return False, 'Payment method not available at this store'

Success (services.py:901):

return True, 'Payment method is valid'

Example Usage:

is_valid, message = PaymentMethodService.validate_method_for_transaction(
    payment_method_id=payment_method_uuid,
    amount=Decimal("50.00"),
    store_id=store_uuid
)

if is_valid:
    # Proceed with payment
    pass
else:
    # Show error to user
    print(f"{message}")

Security Patterns

Credentials Never in Database

FORBIDDEN:

# NEVER store credentials in database
processor.configuration = {
    'api_key': 'sk_live_...',  # SECURITY VIOLATION
    'webhook_secret': 'whsec_...'  # SECURITY VIOLATION
}

CORRECT:

# Store in environment variables
# .env file (not committed to git)
STRIPE_API_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Django settings
from django.conf import settings
api_key = settings.STRIPE_API_KEY  # SECURE

# Database stores non-sensitive config
processor.configuration = {
    'webhook_url': 'https://api.example.com/webhooks/stripe',
    'timeout_seconds': 30,
    'display_name': 'Stripe Production'
}

Idempotency

Problem: Network failures can cause duplicate payment requests.

Solution: Idempotency keys prevent duplicate charges.

Implementation (services.py:350):

payment = TransactionPayment.objects.create(
    # ...
    idempotency_key=metadata.get('idempotency_key') if metadata else None
)

Stripe Example:

stripe.PaymentIntent.create(
    amount=10000,
    currency='usd',
    idempotency_key=str(payment.id)  # Same key = same payment
)

Guarantee: Sending same idempotency key twice results in same PaymentIntent, not duplicate charge.

Dev Mode Safety

Pattern: Allow development without configuring real processors.

Auto-success (services.py:366-377):

if getattr(settings, 'DEBUG', False) and not payment_method.processor:
    payment.status = 'succeeded'
    payment.processor_response = {
        'message': 'Dev mode auto-success',
        'transaction_id': f'dev_mock_{payment.id}',
        'processor': 'dev_mode'
    }
    payment.save()
    return True, payment, 'Payment processed successfully (dev mode)'

Safety: Only when DEBUG=True AND processor=None. Production with real processors always uses real API.


Integration Patterns

Stripe Webhook Flow

Problem: Stripe processes payments asynchronously. Response to PaymentIntent creation is NOT payment confirmation.

Solution: Two-phase pattern - create intent, wait for webhook.

Phase 1: Create PaymentIntent (services.py:508-528):

intent = stripe.PaymentIntent.create(
    amount=int(payment.amount * 100),
    currency='usd',
    # ...
)

# Payment status = 'pending'
payment.status = 'pending'
payment.processor_transaction_id = intent.id
payment.save()

Phase 2: Webhook Confirmation (handled in webhooks.py):

# Stripe sends webhook: payment_intent.succeeded
# Webhook handler updates payment:
payment = TransactionPayment.objects.get(processor_transaction_id=intent.id)
payment.status = 'succeeded'
payment.save()

Why: Stripe Terminal or Stripe.js may take seconds/minutes to collect payment. Backend can't block waiting for customer to tap card.

Cash Drawer Integration

Automatic: Every cash payment creates CashDrawerEvent.

Flow (services.py:259-265):

if payment_method.method_type == 'cash':
    cash_drawer_event = PaymentService._create_cash_drawer_event(
        sale_transaction, payment_amount, metadata or {}
    )
    payment.cash_drawer_event = cash_drawer_event
    payment.save()

Event Tracking (services.py:474-487):

  • eventType='sale': Cash sale (vs 'payout', 'drop', 'adjustment')
  • amount: Cash collected
  • cashBreakdown: Denominations
  • drawerTotalBefore/After: Running balance
  • shiftId: Track per employee shift

Reconciliation: At end of shift, sum all CashDrawerEvent amounts, compare to physical cash count.

Partial Payment Support

POS Scenario: Customer pays $100 transaction with $60 cash + $40 credit card.

Implementation:

# Payment 1: Cash $60
PaymentService.apply_payment_to_transaction(
    transaction_id="TXN-...",
    payment_method_id=cash_uuid,
    tendered_amount=Decimal("60.00")
)
# Transaction balance: $40.00, status: 'pending'

# Payment 2: Credit card $40
PaymentService.apply_payment_to_transaction(
    transaction_id="TXN-...",
    payment_method_id=card_uuid,
    tendered_amount=Decimal("40.00")
)
# Transaction balance: $0.00, status: 'completed'

Auto-completion: When payment_balance reaches $0.00, transaction auto-marks as 'completed'.


Error Handling

Transaction Atomic Wrapper

All state-changing operations:

with transaction.atomic():
    # Multiple database operations
    # Either ALL succeed or ALL rollback

Why: Payment processing involves multiple models (TransactionPayment, SaleTransaction, CashDrawerEvent). Atomic ensures consistency - no partial state.

Graceful Degradation

No Processor in Dev Mode:

if getattr(settings, 'DEBUG', False) and not payment_method.processor:
    # Auto-succeed without real processor
    payment.status = 'succeeded'

Missing Credentials:

if not api_key:
    return False, {
        'message': 'Stripe API key not configured in environment',
        'processor': 'stripe'
    }

Pattern: Return structured error instead of raising exception. Caller decides how to handle.

Logging

All critical operations logged:

Payment Creation:

logger.info(f"Created Stripe PaymentIntent: {intent.id} for payment {payment.id}")

Errors:

logger.error(f"Error processing payment: {e}")
import traceback
logger.error(traceback.format_exc())

Why: Payment processing errors need detailed logs for debugging and compliance audits.


Was this page helpful?