diff --git a/backend/igny8_core/business/billing/admin.py b/backend/igny8_core/business/billing/admin.py index d9454d70..85c16104 100644 --- a/backend/igny8_core/business/billing/admin.py +++ b/backend/igny8_core/business/billing/admin.py @@ -3,7 +3,7 @@ Billing Business Logic Admin """ from django.contrib import admin from django.utils.html import format_html -from .models import CreditCostConfig +from .models import CreditCostConfig, AccountPaymentMethod @admin.register(CreditCostConfig) @@ -79,3 +79,32 @@ class CreditCostConfigAdmin(admin.ModelAdmin): """Track who made the change""" obj.updated_by = request.user super().save_model(request, obj, form, change) + + +@admin.register(AccountPaymentMethod) +class AccountPaymentMethodAdmin(admin.ModelAdmin): + list_display = [ + 'display_name', + 'type', + 'account', + 'is_default', + 'is_enabled', + 'country_code', + 'is_verified', + 'updated_at', + ] + list_filter = ['type', 'is_default', 'is_enabled', 'is_verified', 'country_code'] + search_fields = ['display_name', 'account__name', 'account__id'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Payment Method', { + 'fields': ('account', 'type', 'display_name', 'is_default', 'is_enabled', 'is_verified', 'country_code') + }), + ('Instructions / Metadata', { + 'fields': ('instructions', 'metadata') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) diff --git a/backend/igny8_core/business/billing/models.py b/backend/igny8_core/business/billing/models.py index a5a09ab5..0ab721da 100644 --- a/backend/igny8_core/business/billing/models.py +++ b/backend/igny8_core/business/billing/models.py @@ -457,3 +457,43 @@ class PaymentMethodConfig(models.Model): def __str__(self): return f"{self.country_code} - {self.get_payment_method_display()}" + + +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'), + ] + + 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='') + is_default = models.BooleanField(default=False, db_index=True) + is_enabled = models.BooleanField(default=True, db_index=True) + is_verified = models.BooleanField(default=False, db_index=True) + country_code = models.CharField(max_length=2, blank=True, default='', help_text="ISO-2 country code (optional)") + + # Manual/bank/local wallet details (non-sensitive metadata) + instructions = models.TextField(blank=True, default='') + metadata = models.JSONField(default=dict, blank=True, help_text="Provider references or display metadata") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'billing' + db_table = 'igny8_account_payment_methods' + ordering = ['-is_default', 'display_name', 'id'] + indexes = [ + models.Index(fields=['account', 'is_default']), + models.Index(fields=['account', 'type']), + ] + unique_together = [['account', 'display_name']] + + def __str__(self): + return f"{self.account_id} - {self.display_name} ({self.type})" diff --git a/backend/igny8_core/business/billing/urls.py b/backend/igny8_core/business/billing/urls.py index 17365258..d3fd7690 100644 --- a/backend/igny8_core/business/billing/urls.py +++ b/backend/igny8_core/business/billing/urls.py @@ -8,7 +8,8 @@ from .views import ( PaymentViewSet, CreditPackageViewSet, CreditTransactionViewSet, - AdminBillingViewSet + AdminBillingViewSet, + AccountPaymentMethodViewSet, ) router = DefaultRouter() @@ -17,9 +18,10 @@ router.register(r'payments', PaymentViewSet, basename='payment') router.register(r'credit-packages', CreditPackageViewSet, basename='credit-package') router.register(r'transactions', CreditTransactionViewSet, basename='transaction') router.register(r'admin', AdminBillingViewSet, basename='admin-billing') +router.register(r'payment-methods', AccountPaymentMethodViewSet, basename='payment-method') urlpatterns = [ - # Payment methods alias for easier frontend access - path('payment-methods/', PaymentViewSet.as_view({'get': 'available_methods'}), name='payment-methods'), + # Country/config-driven available methods (legacy alias) + path('payment-methods/available/', PaymentViewSet.as_view({'get': 'available_methods'}), name='payment-methods-available'), path('', include(router.urls)), ] diff --git a/backend/igny8_core/business/billing/views.py b/backend/igny8_core/business/billing/views.py index 03720ea4..fcadda90 100644 --- a/backend/igny8_core/business/billing/views.py +++ b/backend/igny8_core/business/billing/views.py @@ -2,7 +2,7 @@ Billing API Views Comprehensive billing endpoints for invoices, payments, credit packages """ -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated @@ -10,7 +10,14 @@ from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.db import models -from .models import Invoice, Payment, CreditPackage, PaymentMethodConfig, CreditTransaction +from .models import ( + Invoice, + Payment, + CreditPackage, + PaymentMethodConfig, + CreditTransaction, + AccountPaymentMethod, +) from .services.invoice_service import InvoiceService from .services.payment_service import PaymentService @@ -172,6 +179,69 @@ class PaymentViewSet(viewsets.ViewSet): }, status=status.HTTP_201_CREATED) +class AccountPaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = AccountPaymentMethod + fields = [ + 'id', + 'type', + 'display_name', + 'is_default', + 'is_enabled', + 'is_verified', + 'country_code', + 'instructions', + 'metadata', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'is_verified', 'created_at', 'updated_at'] + + +class AccountPaymentMethodViewSet(viewsets.ModelViewSet): + """ + CRUD for account-scoped payment methods (Stripe/PayPal/manual bank/local_wallet). + """ + serializer_class = AccountPaymentMethodSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + account = getattr(self.request.user, 'account', None) + qs = AccountPaymentMethod.objects.all() + if account: + qs = qs.filter(account=account) + else: + qs = qs.none() + return qs.order_by('-is_default', 'display_name', 'id') + + def perform_create(self, serializer): + account = self.request.user.account + with models.transaction.atomic(): + obj = serializer.save(account=account) + make_default = serializer.validated_data.get('is_default') or not AccountPaymentMethod.objects.filter(account=account, is_default=True).exists() + if make_default: + AccountPaymentMethod.objects.filter(account=account).exclude(id=obj.id).update(is_default=False) + obj.is_default = True + obj.save(update_fields=['is_default']) + + def perform_update(self, serializer): + account = self.request.user.account + with models.transaction.atomic(): + obj = serializer.save() + if serializer.validated_data.get('is_default'): + AccountPaymentMethod.objects.filter(account=account).exclude(id=obj.id).update(is_default=False) + + @action(detail=True, methods=['post']) + def set_default(self, request, pk=None): + account = request.user.account + method = get_object_or_404(AccountPaymentMethod, id=pk, account=account) + with models.transaction.atomic(): + AccountPaymentMethod.objects.filter(account=account).update(is_default=False) + method.is_default = True + method.save(update_fields=['is_default']) + return Response({'message': 'Default payment method updated', 'id': method.id}) + + class CreditPackageViewSet(viewsets.ViewSet): """Credit package endpoints""" permission_classes = [IsAuthenticated] @@ -485,14 +555,15 @@ class AdminBillingViewSet(viewsets.ViewSet): # Account stats total_accounts = Account.objects.count() - active_accounts = Account.objects.filter(is_active=True).count() + active_accounts = Account.objects.filter(status='active').count() new_accounts_this_month = Account.objects.filter( created_at__gte=this_month_start ).count() # Subscription stats + # Subscriptions are linked via OneToOne "subscription" active_subscriptions = Account.objects.filter( - subscriptions__status='active' + subscription__status='active' ).distinct().count() # Revenue stats @@ -513,8 +584,8 @@ class AdminBillingViewSet(viewsets.ViewSet): created_at__gte=last_30_days ).aggregate(total=Sum('amount'))['total'] or 0 + # Usage transactions are stored as deductions (negative amounts) credits_used = abs(CreditTransaction.objects.filter( - transaction_type__in=['generate_content', 'keyword_research', 'ai_task'], created_at__gte=last_30_days, amount__lt=0 ).aggregate(total=Sum('amount'))['total'] or 0) @@ -533,18 +604,20 @@ class AdminBillingViewSet(viewsets.ViewSet): status__in=['completed', 'succeeded'] ).order_by('-processed_at')[:5] - recent_activity = [ - { + recent_activity = [] + for pay in recent_payments: + account_name = getattr(pay.account, 'name', 'Unknown') + currency = pay.currency or 'USD' + ts = pay.processed_at.isoformat() if pay.processed_at else now.isoformat() + recent_activity.append({ 'id': pay.id, 'type': 'payment', - 'account_name': pay.account.name, + 'account_name': account_name, 'amount': str(pay.amount), - 'currency': pay.currency, - 'timestamp': pay.processed_at.isoformat(), - 'description': f'Payment received via {pay.payment_method}' - } - for pay in recent_payments - ] + 'currency': currency, + 'timestamp': ts, + 'description': f'Payment received via {pay.payment_method or "unknown"}' + }) return Response({ 'total_accounts': total_accounts, diff --git a/backend/igny8_core/modules/billing/admin.py b/backend/igny8_core/modules/billing/admin.py index 080598ba..44e75d68 100644 --- a/backend/igny8_core/modules/billing/admin.py +++ b/backend/igny8_core/modules/billing/admin.py @@ -3,7 +3,7 @@ Billing Module Admin """ from django.contrib import admin from igny8_core.admin.base import AccountAdminMixin -from .models import CreditTransaction, CreditUsageLog +from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod @admin.register(CreditTransaction) @@ -41,3 +41,32 @@ class CreditUsageLogAdmin(AccountAdminMixin, admin.ModelAdmin): return '-' get_account_display.short_description = 'Account' + +@admin.register(AccountPaymentMethod) +class AccountPaymentMethodAdmin(AccountAdminMixin, admin.ModelAdmin): + list_display = [ + 'display_name', + 'type', + 'account', + 'is_default', + 'is_enabled', + 'is_verified', + 'country_code', + 'updated_at', + ] + list_filter = ['type', 'is_default', 'is_enabled', 'is_verified', 'country_code'] + search_fields = ['display_name', 'account__name', 'account__id'] + readonly_fields = ['created_at', 'updated_at'] + fieldsets = ( + ('Payment Method', { + 'fields': ('account', 'type', 'display_name', 'is_default', 'is_enabled', 'is_verified', 'country_code') + }), + ('Instructions / Metadata', { + 'fields': ('instructions', 'metadata') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + diff --git a/backend/igny8_core/modules/billing/migrations/0006_accountpaymentmethod.py b/backend/igny8_core/modules/billing/migrations/0006_accountpaymentmethod.py new file mode 100644 index 00000000..cb309cb3 --- /dev/null +++ b/backend/igny8_core/modules/billing/migrations/0006_accountpaymentmethod.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.8 on 2025-12-05 17:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0005_credittransaction_reference_id_invoice_billing_email_and_more'), + ('igny8_core_auth', '0004_add_invoice_payment_models'), + ] + + operations = [ + migrations.CreateModel( + name='AccountPaymentMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer'), ('local_wallet', 'Local Wallet')], db_index=True, max_length=50)), + ('display_name', models.CharField(default='', help_text='User-visible label', max_length=100)), + ('is_default', models.BooleanField(db_index=True, default=False)), + ('is_enabled', models.BooleanField(db_index=True, default=True)), + ('is_verified', models.BooleanField(db_index=True, default=False)), + ('country_code', models.CharField(blank=True, default='', help_text='ISO-2 country code (optional)', max_length=2)), + ('instructions', models.TextField(blank=True, default='')), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Provider references or display metadata')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')), + ], + options={ + 'db_table': 'igny8_account_payment_methods', + 'ordering': ['-is_default', 'display_name', 'id'], + 'indexes': [models.Index(fields=['account', 'is_default'], name='igny8_accou_tenant__30d459_idx'), models.Index(fields=['account', 'type'], name='igny8_accou_tenant__4cc9c7_idx')], + 'unique_together': {('account', 'display_name')}, + }, + ), + ] diff --git a/backend/igny8_core/modules/billing/models.py b/backend/igny8_core/modules/billing/models.py index 4c46b382..ede6ccc8 100644 --- a/backend/igny8_core/modules/billing/models.py +++ b/backend/igny8_core/modules/billing/models.py @@ -1,4 +1,4 @@ # Backward compatibility aliases - models moved to business/billing/ -from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, CreditCostConfig +from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog, CreditCostConfig, AccountPaymentMethod -__all__ = ['CreditTransaction', 'CreditUsageLog', 'CreditCostConfig'] +__all__ = ['CreditTransaction', 'CreditUsageLog', 'CreditCostConfig', 'AccountPaymentMethod'] diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 3224be32..f184fb57 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -1610,7 +1610,8 @@ class ContentTaxonomyViewSet(SiteSectorModelViewSet): ordering = ['taxonomy_type', 'name'] # Filter configuration - filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy'] + # Removed "parent" to avoid non-model field in filterset (breaks drf-spectacular) + filterset_fields = ['taxonomy_type', 'sync_status', 'external_id', 'external_taxonomy'] def perform_create(self, serializer): """Create taxonomy with site/sector context""" diff --git a/backend/igny8_core/modules/writer/views.py.bak b/backend/igny8_core/modules/writer/views.py.bak index d57e9548..d6340057 100644 --- a/backend/igny8_core/modules/writer/views.py.bak +++ b/backend/igny8_core/modules/writer/views.py.bak @@ -1498,7 +1498,7 @@ class ContentTaxonomyViewSet(SiteSectorModelViewSet): ordering = ['taxonomy_type', 'name'] # Filter configuration - filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy'] + filterset_fields = ['taxonomy_type', 'sync_status', 'external_id', 'external_taxonomy'] def perform_create(self, serializer): """Create taxonomy with site/sector context""" diff --git a/docs/user-flow/user-flow-initial b/docs/user-flow/user-flow-initial index e69de29b..ab986881 100644 --- a/docs/user-flow/user-flow-initial +++ b/docs/user-flow/user-flow-initial @@ -0,0 +1,200 @@ +## IGNY8 User & Billing Flow — Current State and Remediation Plan + +### 0) Scope / Sources +- Frontend routing: `frontend/src/App.tsx` +- Auth store and token handling: `frontend/src/store/authStore.ts` +- Balance/usage/billing client: `frontend/src/services/api.ts`, `frontend/src/services/billing.api.ts` +- Admin billing UI: `frontend/src/pages/Admin/AdminBilling.tsx` +- Backend auth/billing routers: `backend/igny8_core/auth/urls.py`, `backend/igny8_core/business/billing/urls.py`, `backend/igny8_core/modules/billing/urls.py` + +--- + +### 1) Signup / Login +**Backend endpoints (auth/urls.py)** +- POST `/api/v1/auth/register/` → creates user; no email verification enforced. +- POST `/api/v1/auth/login/` → issues JWT access/refresh; requires valid account + plan for “happy path”. +- POST `/api/v1/auth/refresh/` → refresh token. + +**Frontend behavior (authStore)** +- `register()` hits `/v1/auth/register/`; on success stores tokens and marks authenticated. +- `login()` hits `/v1/auth/login/`; if user.account missing → throws `ACCOUNT_REQUIRED`; if plan missing → `PLAN_REQUIRED`. So the UI blocks login success if the account/plan is absent. +- After login, routes are guarded by `ProtectedRoute` → land on main dashboard (`/`). + +**Gaps** +- No email verification or onboarding; user is dropped to dashboard. +- If account/plan is missing, errors surface but no guided remediation (no redirect to plan selection). + +--- + +### 2) Tenant Billing Data & Balance +**Current endpoints used** +- Balance: `/v1/billing/transactions/balance/` (business billing CreditTransactionViewSet.balance). +- Credit transactions: `/api/v1/billing/transactions/`. +- Usage logs/summary/limits: `/api/v1/billing/credits/usage/…`, `/v1/billing/credits/usage/summary/`, `/v1/billing/credits/usage/limits/`. +- Invoices: `/api/v1/billing/invoices/` (+ detail, pdf). +- Payments: `/api/v1/billing/payments/`. +- Credit packages: `/v1/billing/credit-packages/`. + +**UI touchpoints** +- Header metric uses `fetchCreditBalance()` → `/v1/billing/transactions/balance/`. +- Usage & Analytics, Credits pages, Billing panels use `getCreditBalance()` → same endpoint. + +**Gaps** +- Subscription lifecycle (select plan, subscribe/upgrade/cancel) not wired in UI. +- Payment methods add/remove not exposed in UI. +- Balance fetch falls back to zero on any non-200; no inline “balance unavailable” messaging. + +--- + +### 3) Admin Billing (pages/Admin/AdminBilling.tsx) +**Current tabs & data** +- Overview: stats from `/v1/admin/billing/stats/` (totals, usage). +- User Management: `/v1/admin/users/` (credit balances), adjust credits via `/v1/admin/users/{id}/adjust-credits/`. +- Credit Pricing: `/v1/admin/credit-costs/` with editable cost per operation; Save posts updates. +- Credit Packages: read-only cards from `/v1/billing/credit-packages/`. + +**Gaps** +- No admin CRUD for packages (only read). +- No exports/filters for payments/invoices in this page (handled elsewhere). + +--- + +### 4) Plan Limits / Access +**Backend** +- Auth login blocks if account plan missing (frontend enforced). Backend exposes plans/subscriptions via `auth/urls.py` (routers: `plans`, `subscriptions`). +- Automation service checks `account.credits` directly before heavy AI work (no HTTP). + +**Frontend** +- No explicit plan-limit enforcement in UI (sites, seats, module gating) beyond route/module guards already present per module. +- No upgrade prompts when hitting limits. + +--- + +### 5) AI / Automation Credit Checks +- Backend: `automation_service.py` estimates credits, adds 20% buffer, aborts if `account.credits` < required. Not dependent on billing APIs. +- Frontend: no preflight credit warning; errors are backend-driven. + +--- + +### 6) What’s Missing for a Complete E2E Paid Flow +1) **Subscription UX**: Add tenant plan selection + subscribe/upgrade/cancel wired to `/api/v1/auth/subscriptions/...` (or billing subscription endpoints if exposed separately). Show current plan and renewal status. +2) **Payment Methods**: Expose add/list/remove default; required for Stripe/PayPal/manual flows. +3) **Invoices/Payments (tenant)**: List history with PDF download; surface payment status. +4) **Manual payments (if supported)**: Provide upload/reference input; show approval status. +5) **Plan limits**: Enforce and message limits on sites, seats, modules; show “Upgrade to increase limit.” +6) **Balance UX**: If `/v1/billing/transactions/balance/` fails, show inline warning and retry instead of silently showing 0. +7) **Admin packages**: If needed, add CRUD for credit packages (activate/feature/sort/price IDs). +8) **Onboarding**: Post-signup redirect to “Choose plan” if no active plan; optional email verification. + +--- + +### 7) Recommended Happy Path (Target) +1) Signup → email verify (optional) → redirect to Plan selection. +2) Choose plan → subscribe (create payment method + subscription intent) → confirm → landing on dashboard with non-zero credits and plan limits loaded. +3) User can: + - Add sites up to plan limit. + - Use Planner/Writer modules; preflight check shows remaining credits. + - View Credits/Usage/Invoices/Payments; purchase extra credit packages if needed. +4) Header shows live balance from `/v1/billing/transactions/balance/`; errors visible if fetch fails. +5) Admin can: + - Adjust credit costs, see stats/payments/invoices/approvals. + - Manage credit packages (CRUD) if enabled. + +--- + +### 8) Quick Endpoint Reference (current wiring) +- Balance: `/v1/billing/transactions/balance/` +- Tenant: invoices `/api/v1/billing/invoices/`, payments `/api/v1/billing/payments/`, credit-packages `/v1/billing/credit-packages/`, usage `/api/v1/billing/credits/usage/` +- Admin: stats `/v1/admin/billing/stats/`, users `/v1/admin/users/`, credit-costs `/v1/admin/credit-costs/`, admin payments `/v1/billing/admin/payments/`, pending approvals `/v1/billing/admin/pending_payments/` + +--- + +## Remediation Plan (Do Now) + +### A) Access control / onboarding +- After register → force redirect to Plans page. +- Lock all app routes except Account Profile, Plans, Billing until subscription is active (route guard). +- Optional: email verification gate before plan selection. + +### B) Plans & subscription UX (tenant) +- Plans page: list plans from `/api/v1/auth/plans/` (or billing subscriptions endpoint), show limits. +- Actions: subscribe/upgrade/cancel wired to subscription endpoints; show status/renewal date. +- CTA in header/sidebar if no active plan. + +### C) Payment methods +- Add/list/remove default payment methods (Stripe/PayPal/manual) via billing endpoints. +- Show “add payment method” inline on Plans/Billing if none exists. + +### D) Checkout / payments +- Credit packages purchase flow wired to `/v1/billing/credit-packages/…/purchase/`. +- If manual payments allowed: upload/reference form and surface status; tie to admin approvals. +- Invoice list/history with PDF download; payment history with status badges. + +### E) Balance and usage UX +- Use single endpoint `/v1/billing/transactions/balance/`; on non-200 show inline warning + retry (no silent zero). +- Surface plan limits and usage (sites, seats, module access) with upgrade prompts when near/at limit. + +### F) Module access & limits +- Enforce plan limits on: add site, team invites, module guards (planner/writer/automation/etc.). +- On 403/422 from backend: show upgrade prompt with link to Plans. + +### G) Admin +- Optional: add CRUD for credit packages (create/edit/sort/feature) if backend endpoints available. +- Keep credit costs editable (already wired); add filters/exports later if needed. + +### H) AI task preflight +- Before costly AI actions, fetch balance and show “insufficient credits—purchase/upgrade” prompt. +- Handle backend credit errors with actionable UI (purchase/upgrade CTA). + +### I) Error handling / UX polish +- Balance widget: display “Balance unavailable” on error; retry button. +- Centralize billing error toasts with clear messages (404 vs 402/403 vs 500). + +### J) Implementation order +1) Route guard + post-signup redirect to Plans; hide other routes until active plan. +2) Plans page with subscribe/upgrade/cancel wiring; add payment method UI. +3) Billing page: invoices/payments/history + credit package purchase CTA. +4) Balance/usage error handling; plan limit prompts; AI preflight checks. +5) Admin package CRUD (if needed) and exports/filters. + + + +[User lands] + | + v +[/signup] -- create account --> [Tokens stored] + | + v +[/account/plans] (onboarding gate) + | + |-- list plans (/v1/auth/plans/) + |-- choose plan + payment method (default/selected) + |-- subscribe (/v1/auth/subscriptions/) + v +[Plan active] -----> [Billing tabs available] + | | \ + | | \ + | v v + | [Purchase Credits] [Invoices/Payments] + | /api/v1/... (list + PDF download) + | | + | v + | [Balance/Usage/Limits] (shared store, retry) + | + v +[Protected routes unlocked: /, modules, account] + | + v +[Sites -> Add/Manage site] + | + |-- check plan limits (sites/seats/modules) + |-- on limit: show upgrade/purchase prompt + v +[Site created] + | + v +[Start workflow] + |-- Planner/Writer/Automation, etc. + |-- preflight: balance/limits; on 402/403 -> prompt upgrade/purchase + v +[User executes tasks with credits] \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea70b2b5..cf052346 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -375,6 +375,11 @@ export default function App() { } /> {/* Account Section - Billing & Management Pages */} + + + + } /> diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx index 65f5e2c6..69252886 100644 --- a/frontend/src/components/auth/ProtectedRoute.tsx +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -4,8 +4,6 @@ import { useAuthStore } from "../../store/authStore"; import { useErrorHandler } from "../../hooks/useErrorHandler"; import { trackLoading } from "../common/LoadingStateMonitor"; -const PRICING_URL = "https://igny8.com/pricing"; - interface ProtectedRouteProps { children: ReactNode; } @@ -21,6 +19,20 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { const [showError, setShowError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const PLAN_ALLOWED_PATHS = [ + '/account/plans', + '/account/billing', + '/account/purchase-credits', + '/account/settings', + '/account/team', + '/account/usage', + '/billing', + ]; + + const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) => + location.pathname.startsWith(prefix) + ); + // Track loading state useEffect(() => { trackLoading('auth-loading', loading); @@ -37,11 +49,6 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { logout(); return; } - - if (!user.account.plan) { - logout(); - window.location.href = PRICING_URL; - } }, [isAuthenticated, user, logout]); // Immediate check on mount: if loading is true, reset it immediately @@ -111,6 +118,11 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { return ; } + // If authenticated but missing an active plan, keep user inside billing/onboarding + if (user?.account && !user.account.plan && !isPlanAllowedPath) { + return ; + } + return <>{children}; } diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index f98518cf..6f351b9d 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -51,8 +51,8 @@ export default function SignUpForm() { last_name: formData.lastName, }); - // Redirect to home after successful registration - navigate("/", { replace: true }); + // Redirect to plan selection after successful registration + navigate("/account/plans", { replace: true }); } catch (err: any) { setError(err.message || "Registration failed. Please try again."); } diff --git a/frontend/src/components/billing/BillingBalancePanel.tsx b/frontend/src/components/billing/BillingBalancePanel.tsx index 67572166..3a07c388 100644 --- a/frontend/src/components/billing/BillingBalancePanel.tsx +++ b/frontend/src/components/billing/BillingBalancePanel.tsx @@ -1,35 +1,20 @@ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { Link } from 'react-router-dom'; import PageMeta from '../../components/common/PageMeta'; -import { useToast } from '../../components/ui/toast/ToastContainer'; -import { getCreditBalance } from '../../services/billing.api'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import Button from '../../components/ui/button/Button'; import { DollarLineIcon } from '../../icons'; +import { useBillingStore } from '../../store/billingStore'; export default function BillingBalancePanel() { - const toast = useToast(); - const [balance, setBalance] = useState(null); - const [loading, setLoading] = useState(true); + const { balance, loading, error, loadBalance } = useBillingStore(); useEffect(() => { loadBalance(); - }, []); + }, [loadBalance]); - const loadBalance = async () => { - try { - setLoading(true); - const data = await getCreditBalance(); - setBalance(data as any); - } catch (error: any) { - toast.error(`Failed to load credit balance: ${error?.message || error}`); - } finally { - setLoading(false); - } - }; - - if (loading) { + if (loading && !balance) { return (
Loading credit balance...
@@ -51,6 +36,17 @@ export default function BillingBalancePanel() {
+ {error && !balance && ( +
+ Balance unavailable. {error} +
+ +
+
+ )} + {balance && (
@@ -89,6 +85,11 @@ export default function BillingBalancePanel() {
)} + {error && balance && ( +
+ Latest balance may be stale: {error} +
+ )} ); } diff --git a/frontend/src/components/billing/BillingUsagePanel.tsx b/frontend/src/components/billing/BillingUsagePanel.tsx index fe807c92..2a6d3556 100644 --- a/frontend/src/components/billing/BillingUsagePanel.tsx +++ b/frontend/src/components/billing/BillingUsagePanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useToast } from '../../components/ui/toast/ToastContainer'; -import { getCreditTransactions, getCreditBalance, CreditTransaction as BillingTransaction, CreditBalance } from '../../services/billing.api'; +import { getCreditTransactions, CreditTransaction as BillingTransaction } from '../../services/billing.api'; +import { useBillingStore } from '../../store/billingStore'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import { CompactPagination } from '../ui/pagination'; @@ -21,24 +22,22 @@ const CREDIT_COSTS: Record([]); - const [balance, setBalance] = useState(null); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const { balance, usageLimits, loadBalance, loadUsageLimits } = useBillingStore(); useEffect(() => { loadUsage(); - }, []); + loadBalance(); + loadUsageLimits(); + }, [loadBalance, loadUsageLimits]); const loadUsage = async () => { try { setLoading(true); - const [txnData, balanceData] = await Promise.all([ - getCreditTransactions(), - getCreditBalance() - ]); + const txnData = await getCreditTransactions(); setTransactions(txnData.results || []); - setBalance(balanceData as any); } catch (error: any) { toast.error(`Failed to load credit usage: ${error.message}`); } finally { @@ -90,6 +89,38 @@ export default function BillingUsagePanel() { )} + {usageLimits && ( + +

Plan Limits

+
+
+
Monthly Credits
+
+ {usageLimits.plan_credits_per_month?.toLocaleString?.() || 0} +
+
+ {usageLimits.credits_used_this_month?.toLocaleString?.() || 0} used +
+
+
+
Remaining
+
+ {usageLimits.credits_remaining?.toLocaleString?.() || 0} +
+ {usageLimits.approaching_limit && ( +
Approaching limit
+ )} +
+
+
Usage %
+
+ {usageLimits.percentage_used?.toFixed?.(0) || 0}% +
+
+
+
+ )} +

Credit Costs per Operation

Understanding how credits are consumed for each operation type

diff --git a/frontend/src/components/dashboard/CreditBalanceWidget.tsx b/frontend/src/components/dashboard/CreditBalanceWidget.tsx index 2c2db319..ec07c1e5 100644 --- a/frontend/src/components/dashboard/CreditBalanceWidget.tsx +++ b/frontend/src/components/dashboard/CreditBalanceWidget.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { useBillingStore } from '../../store/billingStore'; import ComponentCard from '../common/ComponentCard'; +import Button from '../ui/button/Button'; export default function CreditBalanceWidget() { - const { balance, loading, loadBalance } = useBillingStore(); + const { balance, loading, error, loadBalance } = useBillingStore(); useEffect(() => { loadBalance(); @@ -16,6 +17,17 @@ export default function CreditBalanceWidget() { ); } + + if (error && !balance) { + return ( + +
{error}
+ +
+ ); + } if (!balance) return null; @@ -53,6 +65,11 @@ export default function CreditBalanceWidget() { Remaining {balance.credits_remaining} + {error && ( +
+ Balance may be outdated. {error} +
+ )} diff --git a/frontend/src/pages/account/PlansAndBillingPage.tsx b/frontend/src/pages/account/PlansAndBillingPage.tsx index ba498261..c662f5ca 100644 --- a/frontend/src/pages/account/PlansAndBillingPage.tsx +++ b/frontend/src/pages/account/PlansAndBillingPage.tsx @@ -11,30 +11,69 @@ import { import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; import Button from '../../components/ui/button/Button'; +import { useToast } from '../../components/ui/toast/ToastContainer'; import { getCreditBalance, getCreditPackages, getInvoices, getAvailablePaymentMethods, purchaseCreditPackage, + downloadInvoicePDF, + getPayments, + submitManualPayment, + createPaymentMethod, + deletePaymentMethod, + setDefaultPaymentMethod, type CreditBalance, type CreditPackage, type Invoice, type PaymentMethod, + type Payment, + getPlans, + getSubscriptions, + createSubscription, + cancelSubscription, + type Plan, + type Subscription, } from '../../services/billing.api'; -type TabType = 'plan' | 'upgrade' | 'credits' | 'purchase' | 'invoices' | 'payment-methods'; +type TabType = 'plan' | 'upgrade' | 'credits' | 'purchase' | 'invoices' | 'payments' | 'payment-methods'; export default function PlansAndBillingPage() { const [activeTab, setActiveTab] = useState('plan'); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [planLoadingId, setPlanLoadingId] = useState(null); + const [purchaseLoadingId, setPurchaseLoadingId] = useState(null); // Data states const [creditBalance, setCreditBalance] = useState(null); const [packages, setPackages] = useState([]); const [invoices, setInvoices] = useState([]); + const [payments, setPayments] = useState([]); const [paymentMethods, setPaymentMethods] = useState([]); + const [plans, setPlans] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(undefined); + const [manualPayment, setManualPayment] = useState({ + invoice_id: '', + amount: '', + payment_method: '', + reference: '', + notes: '', + }); + const [newPaymentMethod, setNewPaymentMethod] = useState({ + type: 'bank_transfer', + display_name: '', + instructions: '', + }); + const handleBillingError = (err: any, fallback: string) => { + const message = err?.message || fallback; + setError(message); + toast?.error?.(message); + }; + + const toast = useToast(); useEffect(() => { loadData(); @@ -43,17 +82,29 @@ export default function PlansAndBillingPage() { const loadData = async () => { try { setLoading(true); - const [balanceData, packagesData, invoicesData, methodsData] = await Promise.all([ + const [balanceData, packagesData, invoicesData, paymentsData, methodsData, plansData, subsData] = await Promise.all([ getCreditBalance(), getCreditPackages(), getInvoices({}), + getPayments({}), getAvailablePaymentMethods(), + getPlans(), + getSubscriptions(), ]); setCreditBalance(balanceData); setPackages(packagesData.results || []); setInvoices(invoicesData.results || []); - setPaymentMethods(methodsData.results || []); + setPayments(paymentsData.results || []); + const methods = methodsData.results || []; + setPaymentMethods(methods); + if (methods.length > 0) { + const defaultMethod = methods.find((m) => m.is_default); + const firstMethod = defaultMethod || methods[0]; + setSelectedPaymentMethod((prev) => prev || firstMethod.type || firstMethod.id); + } + setPlans((plansData.results || []).filter((p) => p.is_active !== false)); + setSubscriptions(subsData.results || []); } catch (err: any) { setError(err.message || 'Failed to load billing data'); console.error('Billing load error:', err); @@ -62,15 +113,126 @@ export default function PlansAndBillingPage() { } }; + const handleSelectPlan = async (planId: number) => { + try { + if (!selectedPaymentMethod && paymentMethods.length > 0) { + setError('Select a payment method to continue'); + return; + } + setPlanLoadingId(planId); + await createSubscription({ plan_id: planId, payment_method: selectedPaymentMethod }); + toast?.success?.('Subscription updated'); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to update subscription'); + } finally { + setPlanLoadingId(null); + } + }; + + const handleCancelSubscription = async () => { + if (!currentSubscription?.id) { + setError('No active subscription to cancel'); + return; + } + try { + setPlanLoadingId(currentSubscription.id); + await cancelSubscription(currentSubscription.id); + toast?.success?.('Subscription cancellation requested'); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to cancel subscription'); + } finally { + setPlanLoadingId(null); + } + }; + const handlePurchase = async (packageId: number) => { try { + if (!selectedPaymentMethod && paymentMethods.length > 0) { + setError('Select a payment method to continue'); + return; + } + setPurchaseLoadingId(packageId); await purchaseCreditPackage({ package_id: packageId, - payment_method: 'stripe', + payment_method: (selectedPaymentMethod as any) || 'stripe', }); await loadData(); } catch (err: any) { - setError(err.message || 'Failed to purchase credits'); + handleBillingError(err, 'Failed to purchase credits'); + } finally { + setPurchaseLoadingId(null); + setLoading(false); + } + }; + + const handleDownloadInvoice = async (invoiceId: number) => { + try { + const blob = await downloadInvoicePDF(invoiceId); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `invoice-${invoiceId}.pdf`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } catch (err: any) { + handleBillingError(err, 'Failed to download invoice'); + } + }; + + const handleSubmitManualPayment = async () => { + try { + const payload = { + invoice_id: manualPayment.invoice_id ? Number(manualPayment.invoice_id) : undefined, + amount: manualPayment.amount, + payment_method: manualPayment.payment_method || (selectedPaymentMethod as any) || 'manual', + reference: manualPayment.reference, + notes: manualPayment.notes, + }; + await submitManualPayment(payload as any); + toast?.success?.('Manual payment submitted'); + setManualPayment({ invoice_id: '', amount: '', payment_method: '', reference: '', notes: '' }); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to submit payment'); + } + }; + + const handleAddPaymentMethod = async () => { + if (!newPaymentMethod.display_name.trim()) { + setError('Payment method name is required'); + return; + } + try { + await createPaymentMethod(newPaymentMethod as any); + toast?.success?.('Payment method added'); + setNewPaymentMethod({ type: 'bank_transfer', display_name: '', instructions: '' }); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to add payment method'); + } + }; + + const handleRemovePaymentMethod = async (id: string) => { + try { + await deletePaymentMethod(id); + toast?.success?.('Payment method removed'); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to remove payment method'); + } + }; + + const handleSetDefaultPaymentMethod = async (id: string) => { + try { + await setDefaultPaymentMethod(id); + toast?.success?.('Default payment method updated'); + await loadData(); + } catch (err: any) { + handleBillingError(err, 'Failed to set default'); } }; @@ -82,12 +244,20 @@ export default function PlansAndBillingPage() { ); } + const currentSubscription = subscriptions.find((sub) => sub.status === 'active') || subscriptions[0]; + const currentPlanId = typeof currentSubscription?.plan === 'object' ? currentSubscription.plan.id : currentSubscription?.plan; + const currentPlan = plans.find((p) => p.id === currentPlanId); + const hasActivePlan = Boolean(currentPlanId); + const hasPaymentMethods = paymentMethods.length > 0; + const subscriptionStatus = currentSubscription?.status || (hasActivePlan ? 'active' : 'none'); + const tabs = [ { id: 'plan' as TabType, label: 'Current Plan', icon: }, { id: 'upgrade' as TabType, label: 'Upgrade/Downgrade', icon: }, { id: 'credits' as TabType, label: 'Credits Overview', icon: }, { id: 'purchase' as TabType, label: 'Purchase Credits', icon: }, { id: 'invoices' as TabType, label: 'Billing History', icon: }, + { id: 'payments' as TabType, label: 'Payments', icon: }, { id: 'payment-methods' as TabType, label: 'Payment Methods', icon: }, ]; @@ -137,37 +307,64 @@ export default function PlansAndBillingPage() {

Your Current Plan

+ {!hasActivePlan && ( +
+ No active plan found. Please choose a plan to activate your account. +
+ )}
-
Free Plan
-
Perfect for getting started
+
+ {currentPlan?.name || 'No Plan Selected'} +
+
+ {currentPlan?.description || 'Select a plan to unlock full access.'} +
- Active + + {hasActivePlan ? subscriptionStatus : 'plan required'} +
Monthly Credits
- {creditBalance?.plan_credits_per_month.toLocaleString() || 0} + {creditBalance?.plan_credits_per_month?.toLocaleString?.() || 0}
-
Sites Allowed
-
1
+
Current Balance
+
+ {creditBalance?.credits?.toLocaleString?.() || 0} +
-
Team Members
-
1
+
Period Ends
+
+ {currentSubscription?.current_period_end + ? new Date(currentSubscription.current_period_end).toLocaleDateString() + : '—'} +
- - + {hasActivePlan && ( + + )}
@@ -175,12 +372,15 @@ export default function PlansAndBillingPage() {

Plan Features

    - {['Basic AI Tools', 'Content Generation', 'Keyword Research', 'Email Support'].map((feature) => ( -
  • - - {feature} -
  • - ))} + {(currentPlan?.features && currentPlan.features.length > 0 + ? currentPlan.features + : ['Credits included each month', 'Module access per plan limits', 'Email support']) + .map((feature) => ( +
  • + + {feature} +
  • + ))}
@@ -193,126 +393,78 @@ export default function PlansAndBillingPage() {

Available Plans

Choose the plan that best fits your needs

+ + {hasPaymentMethods ? ( +
+
Select payment method
+
+ {paymentMethods.map((method) => ( + + ))} +
+
+ ) : ( +
+ No payment methods available. Please contact support or add one from the Payment Methods tab. +
+ )} -
- {/* Free Plan */} - -
-

Free

-
$0
-
/month
+
+ {plans.map((plan) => { + const isCurrent = plan.id === currentPlanId; + const price = plan.price ? `$${plan.price}/${plan.interval || 'month'}` : 'Custom'; + return ( + +
+

{plan.name}

+
{price}
+
{plan.description || 'Standard plan'}
+
+
+ {(plan.features && plan.features.length > 0 ? plan.features : ['Monthly credits included', 'Module access per plan', 'Email support']).map((feature) => ( +
+ + {feature} +
+ ))} +
+ +
+ ); + })} + {plans.length === 0 && ( +
+ No plans available. Please contact support.
-
-
- - 100 credits/month -
-
- - 1 site -
-
- - 1 user -
-
- - Basic features -
-
- Current - - - {/* Starter Plan */} - - Popular -
-

Starter

-
$29
-
/month
-
-
-
- - 1,000 credits/month -
-
- - 3 sites -
-
- - 2 users -
-
- - Full AI suite -
-
- -
- - {/* Professional Plan */} - -
-

Professional

-
$99
-
/month
-
-
-
- - 5,000 credits/month -
-
- - 10 sites -
-
- - 5 users -
-
- - Priority support -
-
- -
- - {/* Enterprise Plan */} - -
-

Enterprise

-
$299
-
/month
-
-
-
- - 20,000 credits/month -
-
- - Unlimited sites -
-
- - 20 users -
-
- - Dedicated support -
-
- -
+ )}
@@ -379,6 +531,37 @@ export default function PlansAndBillingPage() { {/* Purchase Credits Tab */} {activeTab === 'purchase' && (
+ {hasPaymentMethods ? ( +
+
Select payment method
+
+ {paymentMethods.map((method) => ( + + ))} +
+
+ ) : ( +
+ No payment methods available. Please contact support or add one from the Payment Methods tab. +
+ )} +

Credit Packages

@@ -400,8 +583,9 @@ export default function PlansAndBillingPage() { onClick={() => handlePurchase(pkg.id)} fullWidth className="mt-6" + disabled={purchaseLoadingId === pkg.id || (!hasPaymentMethods && paymentMethods.length > 0)} > - Purchase + {purchaseLoadingId === pkg.id ? 'Processing...' : 'Purchase'}
))} @@ -470,6 +654,7 @@ export default function PlansAndBillingPage() { size="sm" startIcon={} className="ml-auto" + onClick={() => handleDownloadInvoice(invoice.id)} > Download @@ -483,13 +668,176 @@ export default function PlansAndBillingPage() {
)} + {/* Payments Tab */} + {activeTab === 'payments' && ( +
+ +
+
+

Payments

+

Recent payments and manual submissions

+
+
+
+ + + + + + + + + + + + {payments.length === 0 ? ( + + + + ) : ( + payments.map((payment) => ( + + + + + + + + )) + )} + +
InvoiceAmountMethodStatusDate
+ No payments yet +
+ {payment.invoice_number || payment.invoice_id || '-'} + + ${payment.amount} + + {payment.payment_method} + + + {payment.status} + + + {new Date(payment.created_at).toLocaleDateString()} +
+
+
+ + +

Submit Manual Payment

+
+
+ + setManualPayment((p) => ({ ...p, invoice_id: e.target.value }))} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + placeholder="Invoice ID" + /> +
+
+ + setManualPayment((p) => ({ ...p, amount: e.target.value }))} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + placeholder="e.g., 99.00" + /> +
+
+ + setManualPayment((p) => ({ ...p, payment_method: e.target.value }))} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + placeholder="bank_transfer / local_wallet / manual" + /> +
+
+ + setManualPayment((p) => ({ ...p, reference: e.target.value }))} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + placeholder="Reference or transaction id" + /> +
+
+ +