feat(billing): add missing payment methods and configurations
- Added migration to include global payment method configurations for Stripe and PayPal (both disabled). - Ensured existing payment methods like bank transfer and manual payment are correctly configured. - Added database constraints and indexes for improved data integrity in billing models. - Introduced foreign key relationship between CreditTransaction and Payment models. - Added webhook configuration fields to PaymentMethodConfig for future payment gateway integrations. - Updated SignUpFormUnified component to handle payment method selection based on user country and plan. - Implemented PaymentHistory component to display user's payment history with status indicators.
This commit is contained in:
@@ -8,6 +8,16 @@ from django.conf import settings
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
|
||||
# Centralized payment method choices - single source of truth
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe (Credit/Debit Card)'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer (Manual)'),
|
||||
('local_wallet', 'Local Wallet (Manual)'),
|
||||
('manual', 'Manual Payment'),
|
||||
]
|
||||
|
||||
|
||||
class CreditTransaction(AccountBaseModel):
|
||||
"""Track all credit transactions (additions, deductions)"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
@@ -23,11 +33,24 @@ class CreditTransaction(AccountBaseModel):
|
||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||
description = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
||||
|
||||
# Payment FK - preferred over reference_id string
|
||||
payment = models.ForeignKey(
|
||||
'billing.Payment',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='credit_transactions',
|
||||
help_text='Payment that triggered this credit transaction'
|
||||
)
|
||||
|
||||
# Deprecated: Use payment FK instead
|
||||
reference_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Optional reference (e.g., payment id, invoice id)"
|
||||
help_text="DEPRECATED: Use payment FK. Legacy reference (e.g., payment id, invoice id)"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -181,6 +204,16 @@ class Invoice(AccountBaseModel):
|
||||
|
||||
invoice_number = models.CharField(max_length=50, unique=True, db_index=True)
|
||||
|
||||
# Subscription relationship
|
||||
subscription = models.ForeignKey(
|
||||
'igny8_core_auth.Subscription',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='invoices',
|
||||
help_text='Subscription this invoice is for (if subscription-based)'
|
||||
)
|
||||
|
||||
# Amounts
|
||||
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
tax = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
@@ -295,13 +328,8 @@ class Payment(AccountBaseModel):
|
||||
('refunded', 'Refunded'), # Payment refunded (rare)
|
||||
]
|
||||
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe (Credit/Debit Card)'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer (Manual)'),
|
||||
('local_wallet', 'Local Wallet (Manual)'),
|
||||
('manual', 'Manual Payment'),
|
||||
]
|
||||
# Use centralized payment method choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='payments')
|
||||
|
||||
@@ -310,7 +338,7 @@ class Payment(AccountBaseModel):
|
||||
currency = models.CharField(max_length=3, default='USD')
|
||||
|
||||
# Status
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', db_index=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending_approval', db_index=True)
|
||||
|
||||
# Payment method
|
||||
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
|
||||
@@ -366,85 +394,6 @@ class Payment(AccountBaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to automatically update related objects when payment is approved.
|
||||
When status changes to 'succeeded', automatically:
|
||||
1. Mark invoice as paid
|
||||
2. Activate subscription
|
||||
3. Activate account
|
||||
4. Add credits
|
||||
"""
|
||||
# Check if status is changing to succeeded
|
||||
is_new = self.pk is None
|
||||
old_status = None
|
||||
|
||||
if not is_new:
|
||||
try:
|
||||
old_payment = Payment.objects.get(pk=self.pk)
|
||||
old_status = old_payment.status
|
||||
except Payment.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If status is changing to succeeded, trigger approval workflow
|
||||
if self.status == 'succeeded' and old_status != 'succeeded':
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
# Set approval timestamp if not set
|
||||
if not self.processed_at:
|
||||
self.processed_at = timezone.now()
|
||||
if not self.approved_at:
|
||||
self.approved_at = timezone.now()
|
||||
|
||||
# Save payment first
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Then update related objects in transaction
|
||||
with transaction.atomic():
|
||||
# 1. Update Invoice
|
||||
if self.invoice:
|
||||
self.invoice.status = 'paid'
|
||||
self.invoice.paid_at = timezone.now()
|
||||
self.invoice.save(update_fields=['status', 'paid_at'])
|
||||
|
||||
# 2. Update Account (MUST be before subscription check)
|
||||
if self.account:
|
||||
self.account.status = 'active'
|
||||
self.account.save(update_fields=['status'])
|
||||
|
||||
# 3. Update Subscription via account.subscription (one-to-one relationship)
|
||||
try:
|
||||
if hasattr(self.account, 'subscription'):
|
||||
subscription = self.account.subscription
|
||||
subscription.status = 'active'
|
||||
subscription.external_payment_id = self.manual_reference or f'payment-{self.id}'
|
||||
subscription.save(update_fields=['status', 'external_payment_id'])
|
||||
|
||||
# 4. Add Credits from subscription plan
|
||||
if subscription.plan and subscription.plan.included_credits > 0:
|
||||
CreditService.add_credits(
|
||||
account=self.account,
|
||||
amount=subscription.plan.included_credits,
|
||||
transaction_type='subscription',
|
||||
description=f'{subscription.plan.name} - Invoice {self.invoice.invoice_number}',
|
||||
metadata={
|
||||
'subscription_id': subscription.id,
|
||||
'invoice_id': self.invoice.id,
|
||||
'payment_id': self.id,
|
||||
'auto_approved': True
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail payment save
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error updating subscription/credits for payment {self.id}: {e}', exc_info=True)
|
||||
else:
|
||||
# Normal save
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CreditPackage(models.Model):
|
||||
@@ -497,12 +446,8 @@ class PaymentMethodConfig(models.Model):
|
||||
Configure payment methods availability per country
|
||||
Allows enabling/disabling manual payments by region
|
||||
"""
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('local_wallet', 'Local Wallet'),
|
||||
]
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
country_code = models.CharField(
|
||||
max_length=2,
|
||||
@@ -526,6 +471,12 @@ class PaymentMethodConfig(models.Model):
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Webhook configuration (Stripe/PayPal)
|
||||
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
|
||||
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
|
||||
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
|
||||
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
|
||||
|
||||
# Order/priority
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
@@ -549,12 +500,8 @@ class AccountPaymentMethod(AccountBaseModel):
|
||||
Account-scoped payment methods (Stripe/PayPal/manual bank/wallet).
|
||||
Only metadata/refs are stored here; no secrets.
|
||||
"""
|
||||
PAYMENT_METHOD_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
('bank_transfer', 'Bank Transfer'),
|
||||
('local_wallet', 'Local Wallet'),
|
||||
]
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
|
||||
type = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES, db_index=True)
|
||||
display_name = models.CharField(max_length=100, help_text="User-visible label", default='')
|
||||
|
||||
Reference in New Issue
Block a user