From 92d16c76a774cff06cc048d308428e4b1a4cf35e Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:26:01 +0500 Subject: [PATCH] Revert "sadasd" This reverts commit 9f85ce4f52f9688c7ccfe8978cd0646fb801991b. --- ...NTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md | 858 ++++-------------- .../0010_add_subscription_plan_field.py | 19 - .../0011_make_site_industry_required.py | 19 - backend/igny8_core/auth/models.py | 12 +- backend/igny8_core/auth/serializers.py | 2 +- backend/igny8_core/business/billing/models.py | 6 - .../migrations/0007_add_invoice_pdf_field.py | 18 - .../src/components/auth/ProtectedRoute.tsx | 12 - frontend/src/components/auth/SignInForm.tsx | 5 - frontend/src/components/auth/SignUpForm.tsx | 18 - frontend/src/store/authStore.ts | 3 - 11 files changed, 198 insertions(+), 774 deletions(-) delete mode 100644 backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py delete mode 100644 backend/igny8_core/auth/migrations/0011_make_site_industry_required.py delete mode 100644 backend/igny8_core/modules/billing/migrations/0007_add_invoice_pdf_field.py diff --git a/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md b/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md index 4ed62b82..4996eb13 100644 --- a/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md +++ b/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md @@ -30,18 +30,12 @@ 6. **Country-Specific Logic Missing** - Pakistan payment methods not filtered 7. **Site Industry Not Required** - Can create sites without industry, then can't add sectors 8. **Broken Paid Signup Flow** - Imports non-existent `billing.Subscription` -9. **Credits Not Single Source** - Account.credits, Plan.included_credits not synchronized -10. **Invoice PDF Missing** - No pdf_file field, no proper PDF generation/download -11. **No Billing Settings Section** - Collect minimal data in signup, need account settings for full billing info **Complexity Issues:** -- Payment method filtering by region not properly implemented +- 4 global payment methods + 1 country-specific = unnecessary complexity - Payment method config table exists but not used in signup flow - Manual payment instructions not shown to users - No clear workflow for payment confirmation after signup -- Credits stored in multiple places instead of single source -- Invoice PDF field missing, no download capability -- Billing data collected but no account settings section for updates ### Target Architecture @@ -72,11 +66,9 @@ Payment (many) │◄── Links to Invoice ``` **Simplified Payment Methods:** -- **Global (2):** Stripe, PayPal (available worldwide) -- **Regional (2):** - - Bank Transfer (UK, USA, Canada, Europe only) - - Local Wallet (Pakistan only - JazzCash/Easypaisa) -- **Total:** 4 payment methods maximum per region +- **Global (3):** Stripe, PayPal, Bank Transfer +- **Country-Specific (1):** Local Wallet (Pakistan only) +- **Total:** 4 payment methods (removed unnecessary complexity) --- @@ -1869,7 +1861,7 @@ docker exec igny8_backend python manage.py shell ```python from igny8_core.business.billing.models import PaymentMethodConfig -# Global methods (available worldwide) +# Global methods (available everywhere) PaymentMethodConfig.objects.get_or_create( country_code='*', payment_method='stripe', @@ -1890,28 +1882,25 @@ PaymentMethodConfig.objects.get_or_create( } ) -# UK, USA, Canada, Europe - Bank Transfer -for country in ['GB', 'US', 'CA', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'IE', 'SE', 'NO', 'DK', 'FI']: - PaymentMethodConfig.objects.get_or_create( - country_code=country, - payment_method='bank_transfer', - defaults={ - 'is_enabled': True, - 'display_name': 'Bank Transfer', - 'instructions': ''' - Bank Name: ABC Bank - Account Name: IGNY8 Inc - Account Number: 123456789 - SWIFT/BIC: ABCPKKA - IBAN: GB00ABCD12345678901234 - - Please transfer the exact invoice amount and include your invoice number as reference. - ''', - 'sort_order': 3 - } - ) +PaymentMethodConfig.objects.get_or_create( + country_code='*', + payment_method='bank_transfer', + defaults={ + 'is_enabled': True, + 'display_name': 'Bank Transfer', + 'instructions': ''' + Bank Name: ABC Bank + Account Name: IGNY8 Inc + Account Number: 123456789 + SWIFT: ABCPKKA + + Please transfer the exact invoice amount and keep the transaction reference. + ''', + 'sort_order': 3 + } +) -# Pakistan - Local Wallet (JazzCash/Easypaisa) +# Pakistan-specific PaymentMethodConfig.objects.get_or_create( country_code='PK', payment_method='local_wallet', @@ -1923,21 +1912,15 @@ PaymentMethodConfig.objects.get_or_create( 'instructions': ''' Send payment to: JazzCash: 03001234567 - Account Title: IGNY8 + Account Name: IGNY8 - After payment: - 1. Note the Transaction ID - 2. Submit confirmation below with Transaction ID - 3. Upload screenshot (optional) + Please keep the transaction ID and confirm payment after sending. ''', 'sort_order': 4 } ) print("Payment method configurations created!") -print("- Global: Stripe, PayPal") -print("- UK/USA/Canada/Europe: + Bank Transfer") -print("- Pakistan: + JazzCash/Easypaisa") ``` #### 3.2 Create Payment Methods API Endpoint @@ -2013,13 +1996,171 @@ def confirm_payment(self, request): ) ``` ---- -**NOTE:** RegisterSerializer already handles signup correctly with existing billing fields in Account model. No changes needed. +#### 3.4 Update RegisterSerializer for Billing Fields +```python +# File: backend/igny8_core/auth/serializers.py ---- +class RegisterSerializer(serializers.ModelSerializer): + # ... existing fields ... + + # Add billing fields + billing_email = serializers.EmailField(required=False, allow_blank=True) + billing_address = serializers.CharField(required=False, allow_blank=True) + billing_city = serializers.CharField(required=False, allow_blank=True) + billing_country = serializers.CharField(required=False, allow_blank=True) + payment_method = serializers.CharField(required=False, default='stripe') + + class Meta: + model = User + fields = [ + 'email', 'password', 'password_confirm', 'first_name', 'last_name', + 'billing_email', 'billing_address', 'billing_city', 'billing_country', + 'payment_method', 'plan_slug' + ] + + def create(self, validated_data): + # ... existing account/user creation ... + + # Update billing fields if provided + billing_email = validated_data.pop('billing_email', None) + billing_address = validated_data.pop('billing_address', None) + billing_city = validated_data.pop('billing_city', None) + billing_country = validated_data.pop('billing_country', None) + payment_method = validated_data.pop('payment_method', 'stripe') + + if billing_email: + account.billing_email = billing_email + if billing_address: + account.billing_address = billing_address + if billing_city: + account.billing_city = billing_city + if billing_country: + account.billing_country = billing_country + + account.save(update_fields=[ + 'billing_email', 'billing_address', 'billing_city', 'billing_country' + ]) + + # Create AccountPaymentMethod if not free trial + if not is_free_trial: + from igny8_core.business.billing.models import AccountPaymentMethod + AccountPaymentMethod.objects.create( + account=account, + type=payment_method, + is_default=True, + is_enabled=True + ) + + # ... rest of creation logic ... +``` -#### 3.4 Admin Payment Approval Interface +#### 3.5 Frontend: Add Billing Form Step +```typescript +// File: frontend/src/components/auth/SignUpForm.tsx + +interface BillingFormData { + billing_email: string; + billing_address: string; + billing_city: string; + billing_country: string; + payment_method: string; +} + +const [step, setStep] = useState<'signup' | 'billing'>('signup'); +const [billingData, setBillingData] = useState({ + billing_email: '', + billing_address: '', + billing_city: '', + billing_country: '', + payment_method: 'stripe' +}); + +// After initial form submission +const handleSignupSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // If paid plan, show billing form + if (planSlug && planSlug !== 'free') { + setStep('billing'); + } else { + // Free trial, submit directly + await submitRegistration(); + } +}; + +// Billing form submission +const handleBillingSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await submitRegistration(); +}; + +const submitRegistration = async () => { + const payload = { + email, + password, + password_confirm: passwordConfirm, + first_name: firstName, + last_name: lastName, + plan_slug: planSlug, + ...(step === 'billing' ? billingData : {}) + }; + + await register(payload); +}; + +// Render billing form +{step === 'billing' && ( +
+

Billing Information

+ + setBillingData({ + ...billingData, + billing_email: e.target.value + })} + required + /> + + setBillingData({ + ...billingData, + billing_address: e.target.value + })} + required + /> + + setBillingData({ + ...billingData, + billing_country: country + })} + /> + + setBillingData({ + ...billingData, + payment_method: method + })} + /> + + +
+)} +``` + +#### 3.6 Admin Payment Approval Interface ```python # File: backend/igny8_core/business/billing/admin.py @@ -2287,53 +2428,28 @@ Create sites (up to 3) → Add sectors → Start using ### Model Changes Summary ```python # Subscription -+ plan = FK(Plan) # NEW - for historical tracking -- payment_method # REMOVE (use property from AccountPaymentMethod) ++ plan = FK(Plan) # NEW +- payment_method # REMOVE (use property) # Invoice -+ pdf_file = FileField # NEW - generated PDF storage - billing_period_start # REMOVE (use property from subscription) - billing_period_end # REMOVE (use property from subscription) - billing_email # REMOVE (use property from metadata/account) # Payment -+ is_current_period = BooleanField # NEW - flag for current billing period - transaction_reference # REMOVE (duplicate of manual_reference) -# Account -+ billing_company, tax_id, billing_phone, billing_state, billing_postal_code # NEW optional fields -! credits - SINGLE SOURCE, populated from plan.included_credits - # Site ! industry = FK(Industry, null=False) # MAKE REQUIRED -# Plan -! included_credits - SOURCE OF TRUTH for all credit amounts +# Account (no changes, keep payment_method for backward compatibility) ``` -### API Endpoints - New & Updated - -**New Endpoints:** +### API Endpoints Created ``` -GET /v1/billing/payment-methods/?country={code} # Filter methods by region -POST /v1/billing/payments/confirm/ # Manual payment confirmation -PATCH /v1/auth/account/billing # Update full billing details -GET /v1/billing/payment-history # Complete payment history -``` - -**Existing Endpoints (Already Implemented):** -``` -POST /v1/auth/register/ # ✅ EXISTS - need to enhance with minimal billing -GET /v1/billing/invoices/{id}/download_pdf/ # ✅ EXISTS - need to serve from pdf_file field -POST /v1/billing/confirm-bank-transfer # ✅ EXISTS - admin manual payment approval -POST /v1/billing/payments/manual # ✅ EXISTS - user submits manual payment -``` - -**Payment Method Regional Configuration:** -``` -Global (all countries): Stripe, PayPal -UK/USA/Canada/Europe: + Bank Transfer -Pakistan: + JazzCash/Easypaisa (local_wallet) +GET /v1/billing/payment-methods/?country=PK +POST /v1/billing/payments/confirm/ +POST /v1/auth/register/ (enhanced with billing fields) ``` ### Database Queries for Verification @@ -2370,589 +2486,3 @@ HAVING a.credits != COALESCE(SUM(ct.amount), 0); -- Expected: 0 rows (all match) ``` ---- - -**NOTE:** Account model already has all billing fields (billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country). No new fields needed. - ---- - -**NOTE:** Invoices already provide complete payment history. The InvoiceViewSet.list() endpoint returns all invoices with payment status. No separate payment history needed. - ---- - -#### 3.5 Invoice PDF Generation & Download -```python -# File: backend/igny8_core/business/billing/models.py - -class Invoice(AccountBaseModel): - # ... existing fields ... - - # ADD NEW FIELD - pdf_file = models.FileField( - upload_to='invoices/pdf/%Y/%m/', - null=True, - blank=True, - help_text='Generated PDF invoice file' - ) - - def generate_pdf(self): - """Generate PDF and save to file field""" - from .services.invoice_service import InvoiceService - - pdf_bytes = InvoiceService.generate_pdf(self) - - # Save to file field - from django.core.files.base import ContentFile - filename = f'invoice-{self.invoice_number}.pdf' - self.pdf_file.save(filename, ContentFile(pdf_bytes), save=True) - - return self.pdf_file.url - - def save(self, *args, **kwargs): - """Auto-generate PDF after invoice is saved""" - super().save(*args, **kwargs) - - # Generate PDF for paid/draft invoices - if self.status in ['paid', 'draft', 'sent'] and not self.pdf_file: - self.generate_pdf() -``` - -**Enhanced PDF Service:** -```python -# File: backend/igny8_core/business/billing/services/invoice_service.py - -from reportlab.lib.pagesizes import letter, A4 -from reportlab.lib import colors -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image -from reportlab.lib.units import inch -from io import BytesIO -from django.conf import settings -import os - -class InvoiceService: - @staticmethod - def generate_pdf(invoice): - """Generate professional PDF invoice""" - buffer = BytesIO() - doc = SimpleDocTemplate(buffer, pagesize=A4, - leftMargin=0.75*inch, rightMargin=0.75*inch, - topMargin=0.75*inch, bottomMargin=0.75*inch) - - elements = [] - styles = getSampleStyleSheet() - - # Company Header - if os.path.exists(os.path.join(settings.STATIC_ROOT, 'logo.png')): - logo = Image(os.path.join(settings.STATIC_ROOT, 'logo.png'), width=2*inch, height=0.5*inch) - elements.append(logo) - - elements.append(Spacer(1, 0.3*inch)) - - # Invoice Title - title_style = ParagraphStyle( - 'CustomTitle', - parent=styles['Heading1'], - fontSize=24, - textColor=colors.HexColor('#1a202c'), - spaceAfter=30, - ) - elements.append(Paragraph('INVOICE', title_style)) - - # Invoice Info Table - info_data = [ - ['Invoice Number:', invoice.invoice_number], - ['Invoice Date:', invoice.invoice_date.strftime('%B %d, %Y')], - ['Due Date:', invoice.due_date.strftime('%B %d, %Y')], - ['Status:', invoice.status.upper()], - ] - - if invoice.paid_at: - info_data.append(['Paid On:', invoice.paid_at.strftime('%B %d, %Y')]) - - info_table = Table(info_data, colWidths=[2*inch, 3*inch]) - info_table.setStyle(TableStyle([ - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, -1), 10), - ('BOTTOMPADDING', (0, 0), (-1, -1), 8), - ])) - elements.append(info_table) - elements.append(Spacer(1, 0.4*inch)) - - # Billing Info - billing_style = ParagraphStyle( - 'BillingStyle', - parent=styles['Normal'], - fontSize=10, - leading=14, - ) - - bill_to = f"Bill To:
" - if invoice.account.billing_company: - bill_to += f"{invoice.account.billing_company}
" - bill_to += f"{invoice.account.billing_email}
" - if invoice.account.billing_address: - bill_to += f"{invoice.account.billing_address}
" - if invoice.account.billing_city: - bill_to += f"{invoice.account.billing_city}, " - if invoice.account.billing_state: - bill_to += f"{invoice.account.billing_state} " - if invoice.account.billing_postal_code: - bill_to += f"{invoice.account.billing_postal_code}
" - if invoice.account.billing_country: - bill_to += f"{invoice.account.billing_country}
" - if invoice.account.tax_id: - bill_to += f"Tax ID: {invoice.account.tax_id}" - - elements.append(Paragraph(bill_to, billing_style)) - elements.append(Spacer(1, 0.4*inch)) - - # Line Items Table - line_items_data = [['Description', 'Quantity', 'Unit Price', 'Amount']] - - for item in invoice.line_items: - line_items_data.append([ - item.get('description', ''), - str(item.get('quantity', 1)), - f"${item.get('unit_price', 0):.2f}", - f"${item.get('amount', 0):.2f}" - ]) - - # Totals - line_items_data.append(['', '', 'Subtotal:', f"${invoice.subtotal:.2f}"]) - if invoice.tax > 0: - line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f"${invoice.tax:.2f}"]) - if invoice.discount > 0: - line_items_data.append(['', '', 'Discount:', f"-${invoice.discount:.2f}"]) - line_items_data.append(['', '', 'Total:', f"${invoice.total:.2f}"]) - - line_table = Table(line_items_data, colWidths=[3.5*inch, 1*inch, 1.5*inch, 1.5*inch]) - line_table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f7fafc')), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#2d3748')), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('ALIGN', (1, 1), (-1, -1), 'RIGHT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 11), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), colors.white), - ('GRID', (0, 0), (-1, -4), 0.5, colors.grey), - ('LINEABOVE', (2, -3), (-1, -3), 1, colors.black), - ('LINEABOVE', (2, -1), (-1, -1), 2, colors.black), - ('FONTNAME', (2, -1), (-1, -1), 'Helvetica-Bold'), - ('FONTSIZE', (2, -1), (-1, -1), 12), - ])) - elements.append(line_table) - elements.append(Spacer(1, 0.5*inch)) - - # Payment Info - if invoice.notes: - notes_style = ParagraphStyle( - 'NotesStyle', - parent=styles['Normal'], - fontSize=9, - textColor=colors.HexColor('#4a5568'), - ) - elements.append(Paragraph(f"Notes: {invoice.notes}", notes_style)) - - # Footer - elements.append(Spacer(1, 0.5*inch)) - footer_style = ParagraphStyle( - 'FooterStyle', - parent=styles['Normal'], - fontSize=8, - textColor=colors.grey, - alignment=1, # Center - ) - elements.append(Paragraph('Thank you for your business!', footer_style)) - elements.append(Paragraph('IGNY8 Inc. | support@igny8.com', footer_style)) - - # Build PDF - doc.build(elements) - buffer.seek(0) - return buffer.getvalue() -``` - -**Updated Download Endpoint (already exists):** -```python -# File: backend/igny8_core/business/billing/views.py - -@action(detail=True, methods=['get']) -def download_pdf(self, request, pk=None): - """Download invoice PDF - serves from file field if exists""" - try: - invoice = self.get_queryset().get(pk=pk) - - # Return existing PDF file if available - if invoice.pdf_file: - response = HttpResponse(invoice.pdf_file.read(), content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"' - return response - - # Generate on-the-fly if not exists - pdf_bytes = InvoiceService.generate_pdf(invoice) - response = HttpResponse(pdf_bytes, content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"' - return response - except Invoice.DoesNotExist: - return error_response(error='Invoice not found', status_code=404, request=request) -``` - -**Migration for pdf_file field:** -```bash -python manage.py makemigrations --name add_invoice_pdf_field -``` - -```python -# Migration file -class Migration(migrations.Migration): - operations = [ - migrations.AddField( - model_name='invoice', - name='pdf_file', - field=models.FileField( - upload_to='invoices/pdf/%Y/%m/', - null=True, - blank=True, - help_text='Generated PDF invoice file' - ), - ), - ] -``` - ---- - -#### 3.9 Credits Single Source of Truth -```python -# File: backend/igny8_core/auth/models.py - -class Plan(models.Model): - """ - Plan model - SOURCE OF TRUTH for credit amounts - """ - included_credits = models.IntegerField( - default=0, - help_text='Monthly credits included in plan (source of truth)' - ) - # ... other fields ... - -class Account(SoftDeletableModel): - """ - Account model - STORES CURRENT BALANCE only - Credits populated from Plan.included_credits - """ - plan = models.ForeignKey(Plan, on_delete=models.PROTECT) - - credits = models.IntegerField( - default=0, - help_text='Current credit balance (populated from plan.included_credits)' - ) - - @property - def monthly_allowance(self): - """Monthly allowance always from plan""" - return self.plan.included_credits - - @property - def total_credits_this_period(self): - """Total credits for current billing period""" - return self.plan.included_credits - - def reset_monthly_credits(self): - """Reset credits at billing cycle - ALWAYS from plan""" - self.credits = self.plan.included_credits - self.save(update_fields=['credits']) - - # Log the reset - CreditTransaction.objects.create( - account=self, - transaction_type='monthly_reset', - amount=self.plan.included_credits, - balance_after=self.credits, - description=f'Monthly credit reset - {self.plan.name} plan', - metadata={'plan_id': self.plan.id, 'included_credits': self.plan.included_credits} - ) - -class Subscription(models.Model): - """ - Subscription - NO credit fields, uses account.credits - """ - account = models.OneToOneField(Account, on_delete=models.CASCADE) - plan = models.ForeignKey(Plan, on_delete=models.PROTECT) # For historical tracking - - # NO credits field here - - @property - def remaining_credits(self): - """Remaining credits from account""" - return self.account.credits - - @property - def monthly_allowance(self): - """Monthly allowance from plan""" - return self.plan.included_credits - -class Invoice(AccountBaseModel): - """ - Invoice - NO credit fields, reference from subscription.plan - """ - subscription = models.ForeignKey(Subscription, on_delete=models.PROTECT, null=True) - - # NO included_credits field - - @property - def plan_credits(self): - """Credits from subscription's plan (historical snapshot)""" - return self.subscription.plan.included_credits if self.subscription else 0 -``` - -**Credit Flow Documentation:** -``` -SINGLE SOURCE OF TRUTH: Plan.included_credits - -Plan.included_credits (1000) - │ - ├─→ Account.credits (populated on signup/renewal) - │ └─→ Updated by CreditService.add_credits() / deduct_credits() - │ - ├─→ Account.monthly_allowance (property, reads from plan) - │ - └─→ Subscription.plan.included_credits (historical record) - └─→ Invoice.plan_credits (property, from subscription.plan) - -RULES: -1. Plan.included_credits = NEVER changes unless admin updates plan -2. Account.credits = CURRENT balance (can be spent/added to) -3. Account.monthly_allowance = ALWAYS reads from plan (property) -4. Monthly reset: Account.credits = Plan.included_credits -5. Invoice references: Use subscription.plan.included_credits (snapshot) -``` - -**CreditService enforcement:** -```python -# File: backend/igny8_core/business/billing/services/credit_service.py - -class CreditService: - @staticmethod - def add_credits(account, amount, description, transaction_type='manual'): - """Add credits - updates ONLY account.credits""" - with transaction.atomic(): - account.credits += amount - account.save(update_fields=['credits']) - - CreditTransaction.objects.create( - account=account, - transaction_type=transaction_type, - amount=amount, - balance_after=account.credits, - description=description - ) - - @staticmethod - def reset_monthly_credits(account): - """Reset to plan amount - SINGLE SOURCE""" - new_balance = account.plan.included_credits - - with transaction.atomic(): - account.credits = new_balance - account.save(update_fields=['credits']) - - CreditTransaction.objects.create( - account=account, - transaction_type='monthly_reset', - amount=new_balance, - balance_after=new_balance, - description=f'Monthly reset - {account.plan.name} plan', - metadata={ - 'plan_id': account.plan.id, - 'plan_credits': account.plan.included_credits - } - ) -``` -```python -# File: backend/igny8_core/business/billing/models.py - -class Payment(AccountBaseModel): - """ - Payment model - handles both one-time and recurring payments - - STRATEGY: - - external_payment_id: Current active payment reference (Stripe sub ID, transaction ID) - - manual_reference: User-provided reference for manual payments - - invoice: Links payment to specific billing period - - Multiple payments can exist for same subscription (historical) - """ - - invoice = models.ForeignKey( - Invoice, - on_delete=models.PROTECT, - related_name='payments', - help_text='Invoice this payment is for (links to billing period)' - ) - - external_payment_id = models.CharField( - max_length=255, - blank=True, - help_text='External payment reference (Stripe charge ID, PayPal txn ID, etc.)' - ) - - manual_reference = models.CharField( - max_length=255, - blank=True, - help_text='User-provided reference for manual payments (bank transfer ID, etc.)' - ) - - is_current_period = models.BooleanField( - default=False, - help_text='Is this payment for the current active billing period?' - ) - - @classmethod - def get_current_period_payment(cls, account): - """Get active payment for current billing period""" - return cls.objects.filter( - account=account, - is_current_period=True, - status='succeeded' - ).first() - - @classmethod - def get_payment_history(cls, account): - """Get all historical payments""" - return cls.objects.filter( - account=account - ).select_related('invoice').order_by('-created_at') -``` - -**Recurring Payment Workflow:** -```python -# File: backend/igny8_core/business/billing/services/subscription_service.py - -class SubscriptionService: - @staticmethod - def renew_subscription(subscription): - """ - Renew subscription - creates NEW invoice and payment - Previous payments remain in history - """ - with transaction.atomic(): - # Mark old payment as historical - Payment.objects.filter( - account=subscription.account, - is_current_period=True - ).update(is_current_period=False) - - # Create new invoice for new period - new_invoice = Invoice.objects.create( - account=subscription.account, - subscription=subscription, - invoice_number=f'INV-{timezone.now().strftime("%Y%m%d")}-{subscription.account.id}', - total=subscription.plan.price, - subtotal=subscription.plan.price, - status='pending' - ) - - # Update subscription period - subscription.current_period_start = timezone.now() - subscription.current_period_end = timezone.now() + timedelta(days=30) - subscription.save() - - # If auto-pay enabled, create payment - if subscription.account.payment_method in ['stripe', 'paypal']: - payment = Payment.objects.create( - account=subscription.account, - invoice=new_invoice, - amount=new_invoice.total, - payment_method=subscription.account.payment_method, - status='processing', - is_current_period=True - ) - - # Process payment via gateway - # ... payment processing logic ... - - return new_invoice -``` - -**Payment History View:** -```python -# File: backend/igny8_core/business/billing/views.py - -@action(detail=False, methods=['get'], url_path='payment-history') -def payment_history(self, request): - """Get complete payment history with invoice details""" - payments = Payment.objects.filter( - account=request.account - ).select_related('invoice', 'invoice__subscription').order_by('-created_at') - - results = [] - for payment in payments: - results.append({ - 'id': payment.id, - 'invoice_number': payment.invoice.invoice_number, - 'amount': str(payment.amount), - 'status': payment.status, - 'payment_method': payment.payment_method, - 'reference': payment.external_payment_id or payment.manual_reference, - 'is_current': payment.is_current_period, - 'billing_period': { - 'start': payment.invoice.subscription.current_period_start.isoformat(), - 'end': payment.invoice.subscription.current_period_end.isoformat() - } if payment.invoice.subscription else None, - 'paid_at': payment.processed_at.isoformat() if payment.processed_at else None, - }) - - return success_response(data={'payments': results}, request=request) -``` - -**Frontend Payment History:** -```typescript -// File: frontend/src/pages/BillingHistory.tsx - -export const PaymentHistory = () => { - const [payments, setPayments] = useState([]); - - return ( -
-

Payment History

- - - - - - - - - - - - - - {payments.map(payment => ( - - - - - - - - - - ))} - -
DateInvoiceAmountMethodReferenceStatusPeriod
{formatDate(payment.paid_at)}{payment.invoice_number}${payment.amount}{payment.payment_method}{payment.reference} - - {payment.status} - - {payment.is_current && Current} - - {payment.billing_period ? - `${formatDate(payment.billing_period.start)} - ${formatDate(payment.billing_period.end)}` - : 'N/A'} -
-
- ); -}; -``` - diff --git a/backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py b/backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py deleted file mode 100644 index 5613a95a..00000000 --- a/backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.8 on 2025-12-08 17:30 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('igny8_core_auth', '0009_add_plan_annual_discount_and_featured'), - ] - - operations = [ - migrations.AddField( - model_name='subscription', - name='plan', - field=models.ForeignKey(blank=True, help_text='Plan for this subscription (historical tracking)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='igny8_core_auth.plan'), - ), - ] diff --git a/backend/igny8_core/auth/migrations/0011_make_site_industry_required.py b/backend/igny8_core/auth/migrations/0011_make_site_industry_required.py deleted file mode 100644 index 0674ed1d..00000000 --- a/backend/igny8_core/auth/migrations/0011_make_site_industry_required.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.2.8 on 2025-12-08 17:31 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('igny8_core_auth', '0010_add_subscription_plan_field'), - ] - - operations = [ - migrations.AlterField( - model_name='site', - name='industry', - field=models.ForeignKey(help_text='Industry this site belongs to (required)', on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry'), - ), - ] diff --git a/backend/igny8_core/auth/models.py b/backend/igny8_core/auth/models.py index b40dbc74..00406aad 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -230,14 +230,6 @@ class Subscription(models.Model): ] account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id') - plan = models.ForeignKey( - 'igny8_core_auth.Plan', - on_delete=models.PROTECT, - related_name='subscriptions', - null=True, - blank=True, - help_text='Plan for this subscription (historical tracking)' - ) stripe_subscription_id = models.CharField( max_length=255, blank=True, @@ -294,7 +286,9 @@ class Site(SoftDeletableModel, AccountBaseModel): 'igny8_core_auth.Industry', on_delete=models.PROTECT, related_name='sites', - help_text="Industry this site belongs to (required)" + null=True, + blank=True, + help_text="Industry this site belongs to" ) is_active = models.BooleanField(default=True, db_index=True) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') diff --git a/backend/igny8_core/auth/serializers.py b/backend/igny8_core/auth/serializers.py index 7a314090..aee09f95 100644 --- a/backend/igny8_core/auth/serializers.py +++ b/backend/igny8_core/auth/serializers.py @@ -287,7 +287,7 @@ class RegisterSerializer(serializers.Serializer): def create(self, validated_data): from django.db import transaction from igny8_core.business.billing.models import CreditTransaction - from igny8_core.auth.models import Subscription + from igny8_core.business.billing.models import Subscription from igny8_core.business.billing.models import AccountPaymentMethod from igny8_core.business.billing.services.invoice_service import InvoiceService from django.utils import timezone diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index d261b6b9..0ab721da 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -215,12 +215,6 @@ class Invoice(AccountBaseModel): # Metadata notes = models.TextField(blank=True) metadata = models.JSONField(default=dict) - pdf_file = models.FileField( - upload_to='invoices/pdf/%Y/%m/', - null=True, - blank=True, - help_text='Generated PDF invoice file' - ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/igny8_core/modules/billing/migrations/0007_add_invoice_pdf_field.py b/backend/igny8_core/modules/billing/migrations/0007_add_invoice_pdf_field.py deleted file mode 100644 index f37f3091..00000000 --- a/backend/igny8_core/modules/billing/migrations/0007_add_invoice_pdf_field.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.8 on 2025-12-08 17:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('billing', '0006_accountpaymentmethod'), - ] - - operations = [ - migrations.AddField( - model_name='invoice', - name='pdf_file', - field=models.FileField(blank=True, help_text='Generated PDF invoice file', null=True, upload_to='invoices/pdf/%Y/%m/'), - ), - ] diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 30212a4d..f669370d 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -39,26 +39,15 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { // Validate account + plan whenever auth/user changes useEffect(() => { - console.log('[ProtectedRoute] Auth state changed:', { - isAuthenticated, - hasUser: !!user, - hasAccount: !!user?.account, - pathname: location.pathname - }); - if (!isAuthenticated) { - console.log('[ProtectedRoute] Not authenticated, will redirect to signin'); return; } if (!user?.account) { - console.error('[ProtectedRoute] User has no account, logging out'); setErrorMessage('This user is not linked to an account. Please contact support.'); logout(); return; } - - console.log('[ProtectedRoute] Auth validation passed'); }, [isAuthenticated, user, logout]); // Immediate check on mount: if loading is true, reset it immediately @@ -125,7 +114,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { // Redirect to signin if not authenticated if (!isAuthenticated) { - console.log('[ProtectedRoute] Redirecting to /signin - not authenticated'); return ; } diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 011a575e..13c97c3d 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -28,11 +28,6 @@ export default function SignInForm() { try { await login(email, password); - - // CRITICAL: Wait for auth state to persist to localStorage before navigating - // Increased to 500ms to ensure Zustand persist middleware completes - await new Promise(resolve => setTimeout(resolve, 500)); - // Redirect to the page user was trying to access, or home const from = (location.state as any)?.from?.pathname || "/"; navigate(from, { replace: true }); diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index f6170ad8..259b85a9 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -85,11 +85,9 @@ export default function SignUpForm({ planDetails: planDetailsProp, planLoading: } try { - console.log('[SignUp] Starting registration...'); // Generate username from email if not provided const username = formData.username || formData.email.split("@")[0]; - console.log('[SignUp] Calling register API...'); const user = await register({ email: formData.email, password: formData.password, @@ -100,29 +98,13 @@ export default function SignUpForm({ planDetails: planDetailsProp, planLoading: plan_slug: planSlug || undefined, }); - console.log('[SignUp] Registration successful, user:', user); - console.log('[SignUp] Auth state before delay:', useAuthStore.getState()); - - // CRITICAL: Wait for auth state to persist to localStorage before navigating - // Increased to 500ms to ensure Zustand persist middleware completes - await new Promise(resolve => setTimeout(resolve, 500)); - - // Verify auth state is persisted - const persistedState = localStorage.getItem('auth-storage'); - console.log('[SignUp] Persisted auth state:', persistedState); - console.log('[SignUp] Auth state after delay:', useAuthStore.getState()); - const status = user?.account?.status; - console.log('[SignUp] Account status:', status); if (status === "pending_payment") { - console.log('[SignUp] Navigating to /account/plans'); navigate("/account/plans", { replace: true }); } else { - console.log('[SignUp] Navigating to /sites'); navigate("/sites", { replace: true }); } } catch (err: any) { - console.error('[SignUp] Registration error:', err); setError(err.message || "Registration failed. Please try again."); } }; diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 21c278a2..0148f794 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -35,7 +35,6 @@ interface AuthState { refreshToken: string | null; isAuthenticated: boolean; loading: boolean; - _hasHydrated: boolean; // Track if persist has completed rehydration // Actions login: (email: string, password: string) => Promise; @@ -45,7 +44,6 @@ interface AuthState { setToken: (token: string | null) => void; refreshToken: () => Promise; refreshUser: () => Promise; - setHasHydrated: (hasHydrated: boolean) => void; } export const useAuthStore = create()( @@ -55,7 +53,6 @@ export const useAuthStore = create()( token: null, isAuthenticated: false, loading: false, // Always start with loading false - will be set true only during login/register - _hasHydrated: false, // Will be set to true after persist rehydration completes login: async (email, password) => { set({ loading: true });