From 9f85ce4f52f9688c7ccfe8978cd0646fb801991b Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Mon, 8 Dec 2025 18:22:10 +0000 Subject: [PATCH] sadasd --- ...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, 774 insertions(+), 198 deletions(-) create mode 100644 backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py create mode 100644 backend/igny8_core/auth/migrations/0011_make_site_industry_required.py create 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 4996eb13..4ed62b82 100644 --- a/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md +++ b/IMPLEMENTATION-PLAN-SIGNUP-TO-PAYMENT-WORKFLOW.md @@ -30,12 +30,18 @@ 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:** -- 4 global payment methods + 1 country-specific = unnecessary complexity +- Payment method filtering by region not properly implemented - 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 @@ -66,9 +72,11 @@ Payment (many) │◄── Links to Invoice ``` **Simplified Payment Methods:** -- **Global (3):** Stripe, PayPal, Bank Transfer -- **Country-Specific (1):** Local Wallet (Pakistan only) -- **Total:** 4 payment methods (removed unnecessary complexity) +- **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 --- @@ -1861,7 +1869,7 @@ docker exec igny8_backend python manage.py shell ```python from igny8_core.business.billing.models import PaymentMethodConfig -# Global methods (available everywhere) +# Global methods (available worldwide) PaymentMethodConfig.objects.get_or_create( country_code='*', payment_method='stripe', @@ -1882,25 +1890,28 @@ PaymentMethodConfig.objects.get_or_create( } ) -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 - } -) +# 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 + } + ) -# Pakistan-specific +# Pakistan - Local Wallet (JazzCash/Easypaisa) PaymentMethodConfig.objects.get_or_create( country_code='PK', payment_method='local_wallet', @@ -1912,15 +1923,21 @@ PaymentMethodConfig.objects.get_or_create( 'instructions': ''' Send payment to: JazzCash: 03001234567 - Account Name: IGNY8 + Account Title: IGNY8 - Please keep the transaction ID and confirm payment after sending. + After payment: + 1. Note the Transaction ID + 2. Submit confirmation below with Transaction ID + 3. Upload screenshot (optional) ''', '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 @@ -1996,171 +2013,13 @@ def confirm_payment(self, request): ) ``` +--- -#### 3.4 Update RegisterSerializer for Billing Fields -```python -# File: backend/igny8_core/auth/serializers.py +**NOTE:** RegisterSerializer already handles signup correctly with existing billing fields in Account model. No changes needed. -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.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 +#### 3.4 Admin Payment Approval Interface ```python # File: backend/igny8_core/business/billing/admin.py @@ -2428,28 +2287,53 @@ Create sites (up to 3) → Add sectors → Start using ### Model Changes Summary ```python # Subscription -+ plan = FK(Plan) # NEW -- payment_method # REMOVE (use property) ++ plan = FK(Plan) # NEW - for historical tracking +- payment_method # REMOVE (use property from AccountPaymentMethod) # 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 -# Account (no changes, keep payment_method for backward compatibility) +# Plan +! included_credits - SOURCE OF TRUTH for all credit amounts ``` -### API Endpoints Created +### API Endpoints - New & Updated + +**New Endpoints:** ``` -GET /v1/billing/payment-methods/?country=PK -POST /v1/billing/payments/confirm/ -POST /v1/auth/register/ (enhanced with billing fields) +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) ``` ### Database Queries for Verification @@ -2486,3 +2370,589 @@ 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 new file mode 100644 index 00000000..5613a95a --- /dev/null +++ b/backend/igny8_core/auth/migrations/0010_add_subscription_plan_field.py @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 00000000..0674ed1d --- /dev/null +++ b/backend/igny8_core/auth/migrations/0011_make_site_industry_required.py @@ -0,0 +1,19 @@ +# 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 00406aad..b40dbc74 100644 --- a/backend/igny8_core/auth/models.py +++ b/backend/igny8_core/auth/models.py @@ -230,6 +230,14 @@ 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, @@ -286,9 +294,7 @@ class Site(SoftDeletableModel, AccountBaseModel): 'igny8_core_auth.Industry', on_delete=models.PROTECT, related_name='sites', - null=True, - blank=True, - help_text="Industry this site belongs to" + help_text="Industry this site belongs to (required)" ) 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 aee09f95..7a314090 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.business.billing.models import Subscription + from igny8_core.auth.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 0ab721da..d261b6b9 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -215,6 +215,12 @@ 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 new file mode 100644 index 00000000..f37f3091 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0007_add_invoice_pdf_field.py @@ -0,0 +1,18 @@ +# 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 f669370d..30212a4d 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -39,15 +39,26 @@ 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 @@ -114,6 +125,7 @@ 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 13c97c3d..011a575e 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -28,6 +28,11 @@ 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 259b85a9..f6170ad8 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -85,9 +85,11 @@ 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, @@ -98,13 +100,29 @@ 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 0148f794..21c278a2 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -35,6 +35,7 @@ interface AuthState { refreshToken: string | null; isAuthenticated: boolean; loading: boolean; + _hasHydrated: boolean; // Track if persist has completed rehydration // Actions login: (email: string, password: string) => Promise; @@ -44,6 +45,7 @@ interface AuthState { setToken: (token: string | null) => void; refreshToken: () => Promise; refreshUser: () => Promise; + setHasHydrated: (hasHydrated: boolean) => void; } export const useAuthStore = create()( @@ -53,6 +55,7 @@ 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 });