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
- Service Classes
- Security Patterns
- Integration Patterns
- Error Handling
- Related Documentation
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.
| Method | Signature | Purpose |
|---|---|---|
get_connection_token | (location_id=None) → Dict | Terminal SDK auth token; {secret, processor_type} |
create_payment_session | (amount_cents, currency, metadata=None) → Dict | Create payment session/intent; {id, client_secret, amount, currency, status} |
verify_webhook | (raw_body: bytes, signature_header: str) → Dict | Validate webhook signature, return parsed event dict |
process_refund | (processor_transaction_id, amount, metadata=None) → Dict | Issue refund; {success, refund_id, status, message} |
test_connection | () → Dict | Validate credentials without processing a payment; {success, message} |
Custom exceptions (also from base.py):
WebhookVerificationError, raised when a webhook signature cannot be verifiedGatewayConnectionError, 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
| Gateway | File | Status |
|---|---|---|
StripeGateway | gateways/stripe_gateway.py | Live in production. Stripe Terminal, PaymentIntents, HMAC webhook verification, refunds |
SquareGateway | gateways/square_gateway.py | Full implementation, all 5 gateway methods. Activates on SQUARE_ACCESS_TOKEN + related env vars |
PayPalGateway | gateways/paypal_gateway.py | Full implementation (Zettle), all 5 gateway methods. Activates on PAYPAL_ZETTLE_* env vars |
CustomBankGateway | gateways/custom_bank_gateway.py | Full 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 amountpayment_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 UUIDpayment_method_id(str): Payment method UUIDtendered_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 balancetender_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 UUIDpayment_method_id(int/UUID): Payment method primary key UUIDamount(Decimal): Payment amountmetadata(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 UUIDpayment_type(str): POS string ('cash', 'card', 'check', 'credit', 'debit')amount(Decimal): Payment amountstore_id(int, optional): Store UUID for store-specific method resolutionmetadata(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 IDcash_breakdown: Denominations (e.g.,{"twenties": 5, "tens": 3})drawer_total_before: Cash drawer balance before saledrawer_total_after: Cash drawer balance after salenotes: Additional notesshift_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 databaseautomatic_payment_methods: Enable all payment methods configured in Stripe dashboardclient_secret: Returned to frontend for confirming payment with Stripe.js
Stripe Workflow:
- Backend: Create PaymentIntent (this method)
- Backend: Return
client_secretto frontend - Frontend: Use
client_secretwith Stripe.js or Terminal SDK to collect payment - Stripe: Process payment, send webhook
- 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 refundamount(Decimal): Refund amountreason(str, optional): Refund reasonmetadata(Dict, optional): Additional metadatasales_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(...)
| Scenario | success | refund | message |
|---|---|---|---|
| Success | True | Refund instance (status='succeeded') | "Refund processed successfully" |
| Failure, no payment method | False | Persisted Refund instance (status='failed') | error description |
| Failure, validation error | False | None | error description |
| Failure, unexpected exception | False | None | exception 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 UUIDamount(Decimal): Transaction amountstore_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 collectedcashBreakdown: DenominationsdrawerTotalBefore/After: Running balanceshiftId: 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.
Related Documentation
- Models - Payment data models
- Serializers - API serialization
- Views - API endpoints
- Webhooks - Processor webhook handlers (to be documented)
- Signals - Sanity sync handlers (to be documented)
- Transactions Services - Transaction business logic