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:
IGNY8 VPS (Salman)
2025-12-09 06:14:44 +00:00
parent 72d0b6b0fd
commit 4d13a57068
36 changed files with 4159 additions and 253 deletions

View File

@@ -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='')