feat: add subscription and billing system with Stripe integration
- Add database models for subscription tiers, vendor subscriptions, add-ons, billing history, and webhook events - Implement BillingService for subscription operations - Implement StripeService for Stripe API operations - Implement StripeWebhookHandler for webhook event processing - Add vendor billing API endpoints for subscription management - Create vendor billing page with Alpine.js frontend - Add limit enforcement for products and team members - Add billing exceptions for proper error handling - Create comprehensive unit tests (40 tests passing) - Add subscription billing documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ This module provides:
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import admin, shop, vendor
|
||||
from app.api.v1.shared import language
|
||||
from app.api.v1.shared import language, webhooks
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -42,3 +42,4 @@ api_router.include_router(shop.router, prefix="/v1/shop", tags=["shop"])
|
||||
# ============================================================================
|
||||
|
||||
api_router.include_router(language.router, prefix="/v1", tags=["language"])
|
||||
api_router.include_router(webhooks.router, prefix="/v1", tags=["webhooks"])
|
||||
|
||||
@@ -101,6 +101,8 @@ class CopyToVendorResponse(BaseModel):
|
||||
copied: int
|
||||
skipped: int
|
||||
failed: int
|
||||
auto_matched: int = 0
|
||||
limit_reached: bool = False
|
||||
details: list[dict] | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,63 @@
|
||||
# External webhooks (Stripe, etc.
|
||||
# app/api/v1/shared/webhooks.py
|
||||
"""
|
||||
External webhook endpoints.
|
||||
|
||||
Handles webhooks from:
|
||||
- Stripe (payments and subscriptions)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import InvalidWebhookSignatureException, WebhookMissingSignatureException
|
||||
from app.services.stripe_service import stripe_service
|
||||
from app.services.stripe_webhook_handler import stripe_webhook_handler
|
||||
|
||||
router = APIRouter(prefix="/webhooks")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/stripe") # public - Stripe webhooks use signature verification
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: str = Header(None, alias="Stripe-Signature"),
|
||||
):
|
||||
"""
|
||||
Handle Stripe webhook events.
|
||||
|
||||
Stripe sends events for:
|
||||
- Subscription lifecycle (created, updated, deleted)
|
||||
- Invoice and payment events
|
||||
- Checkout session completion
|
||||
|
||||
The endpoint verifies the webhook signature and processes events idempotently.
|
||||
"""
|
||||
if not stripe_signature:
|
||||
logger.warning("Stripe webhook received without signature")
|
||||
raise WebhookMissingSignatureException()
|
||||
|
||||
# Get raw body for signature verification
|
||||
payload = await request.body()
|
||||
|
||||
try:
|
||||
# Verify and construct event
|
||||
event = stripe_service.construct_event(payload, stripe_signature)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid Stripe webhook: {e}")
|
||||
raise InvalidWebhookSignatureException(str(e))
|
||||
|
||||
# Process the event
|
||||
db = next(get_db())
|
||||
try:
|
||||
result = stripe_webhook_handler.handle_event(db, event)
|
||||
return {"received": True, **result}
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Stripe webhook: {e}")
|
||||
# Return 200 to prevent Stripe retries for processing errors
|
||||
# The event is marked as failed and can be retried manually
|
||||
return {"received": True, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -16,6 +16,7 @@ from fastapi import APIRouter
|
||||
from . import (
|
||||
analytics,
|
||||
auth,
|
||||
billing,
|
||||
content_pages,
|
||||
customers,
|
||||
dashboard,
|
||||
@@ -73,6 +74,7 @@ router.include_router(media.router, tags=["vendor-media"])
|
||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||
router.include_router(messages.router, tags=["vendor-messages"])
|
||||
router.include_router(analytics.router, tags=["vendor-analytics"])
|
||||
router.include_router(billing.router, tags=["vendor-billing"])
|
||||
|
||||
# Content pages management
|
||||
router.include_router(
|
||||
|
||||
405
app/api/v1/vendor/billing.py
vendored
Normal file
405
app/api/v1/vendor/billing.py
vendored
Normal file
@@ -0,0 +1,405 @@
|
||||
# app/api/v1/vendor/billing.py
|
||||
"""
|
||||
Vendor billing and subscription management endpoints.
|
||||
|
||||
Provides:
|
||||
- Subscription status and usage
|
||||
- Tier listing and comparison
|
||||
- Stripe checkout and portal access
|
||||
- Invoice history
|
||||
- Add-on management
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.services.billing_service import billing_service
|
||||
from app.services.subscription_service import subscription_service
|
||||
from models.database.user import User
|
||||
|
||||
router = APIRouter(prefix="/billing")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Current subscription status and usage."""
|
||||
|
||||
tier_code: str
|
||||
tier_name: str
|
||||
status: str
|
||||
is_trial: bool
|
||||
trial_ends_at: str | None = None
|
||||
period_start: str | None = None
|
||||
period_end: str | None = None
|
||||
cancelled_at: str | None = None
|
||||
cancellation_reason: str | None = None
|
||||
|
||||
# Usage
|
||||
orders_this_period: int
|
||||
orders_limit: int | None
|
||||
orders_remaining: int | None
|
||||
products_count: int
|
||||
products_limit: int | None
|
||||
products_remaining: int | None
|
||||
team_count: int
|
||||
team_limit: int | None
|
||||
team_remaining: int | None
|
||||
|
||||
# Payment
|
||||
has_payment_method: bool
|
||||
last_payment_error: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TierResponse(BaseModel):
|
||||
"""Subscription tier information."""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
price_monthly_cents: int
|
||||
price_annual_cents: int | None = None
|
||||
orders_per_month: int | None = None
|
||||
products_limit: int | None = None
|
||||
team_members: int | None = None
|
||||
features: list[str] = []
|
||||
is_current: bool = False
|
||||
can_upgrade: bool = False
|
||||
can_downgrade: bool = False
|
||||
|
||||
|
||||
class TierListResponse(BaseModel):
|
||||
"""List of available tiers."""
|
||||
|
||||
tiers: list[TierResponse]
|
||||
current_tier: str
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
"""Request to create a checkout session."""
|
||||
|
||||
tier_code: str
|
||||
is_annual: bool = False
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
"""Checkout session response."""
|
||||
|
||||
checkout_url: str
|
||||
session_id: str
|
||||
|
||||
|
||||
class PortalResponse(BaseModel):
|
||||
"""Customer portal session response."""
|
||||
|
||||
portal_url: str
|
||||
|
||||
|
||||
class InvoiceResponse(BaseModel):
|
||||
"""Invoice information."""
|
||||
|
||||
id: int
|
||||
invoice_number: str | None = None
|
||||
invoice_date: str
|
||||
due_date: str | None = None
|
||||
total_cents: int
|
||||
amount_paid_cents: int
|
||||
currency: str
|
||||
status: str
|
||||
pdf_url: str | None = None
|
||||
hosted_url: str | None = None
|
||||
|
||||
|
||||
class InvoiceListResponse(BaseModel):
|
||||
"""List of invoices."""
|
||||
|
||||
invoices: list[InvoiceResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AddOnResponse(BaseModel):
|
||||
"""Add-on product information."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
price_cents: int
|
||||
billing_period: str
|
||||
quantity_unit: str | None = None
|
||||
quantity_value: int | None = None
|
||||
|
||||
|
||||
class VendorAddOnResponse(BaseModel):
|
||||
"""Vendor's purchased add-on."""
|
||||
|
||||
id: int
|
||||
addon_code: str
|
||||
addon_name: str
|
||||
status: str
|
||||
domain_name: str | None = None
|
||||
quantity: int
|
||||
period_start: str | None = None
|
||||
period_end: str | None = None
|
||||
|
||||
|
||||
class AddOnPurchaseRequest(BaseModel):
|
||||
"""Request to purchase an add-on."""
|
||||
|
||||
addon_code: str
|
||||
domain_name: str | None = None # For domain add-ons
|
||||
quantity: int = 1
|
||||
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
"""Request to cancel subscription."""
|
||||
|
||||
reason: str | None = None
|
||||
immediately: bool = False
|
||||
|
||||
|
||||
class CancelResponse(BaseModel):
|
||||
"""Cancellation response."""
|
||||
|
||||
message: str
|
||||
effective_date: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=SubscriptionStatusResponse)
|
||||
def get_subscription_status(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get current subscription status and usage metrics."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
usage = subscription_service.get_usage_summary(db, vendor_id)
|
||||
subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id)
|
||||
|
||||
return SubscriptionStatusResponse(
|
||||
tier_code=subscription.tier,
|
||||
tier_name=tier.name if tier else subscription.tier.title(),
|
||||
status=subscription.status.value,
|
||||
is_trial=subscription.is_in_trial(),
|
||||
trial_ends_at=subscription.trial_ends_at.isoformat()
|
||||
if subscription.trial_ends_at
|
||||
else None,
|
||||
period_start=subscription.period_start.isoformat()
|
||||
if subscription.period_start
|
||||
else None,
|
||||
period_end=subscription.period_end.isoformat()
|
||||
if subscription.period_end
|
||||
else None,
|
||||
cancelled_at=subscription.cancelled_at.isoformat()
|
||||
if subscription.cancelled_at
|
||||
else None,
|
||||
cancellation_reason=subscription.cancellation_reason,
|
||||
orders_this_period=usage.orders_this_period,
|
||||
orders_limit=usage.orders_limit,
|
||||
orders_remaining=usage.orders_remaining,
|
||||
products_count=usage.products_count,
|
||||
products_limit=usage.products_limit,
|
||||
products_remaining=usage.products_remaining,
|
||||
team_count=usage.team_count,
|
||||
team_limit=usage.team_limit,
|
||||
team_remaining=usage.team_remaining,
|
||||
has_payment_method=bool(subscription.stripe_payment_method_id),
|
||||
last_payment_error=subscription.last_payment_error,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tiers", response_model=TierListResponse)
|
||||
def get_available_tiers(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get available subscription tiers for upgrade/downgrade."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
current_tier = subscription.tier
|
||||
|
||||
tier_list, _ = billing_service.get_available_tiers(db, current_tier)
|
||||
|
||||
tier_responses = [TierResponse(**tier_data) for tier_data in tier_list]
|
||||
|
||||
return TierListResponse(tiers=tier_responses, current_tier=current_tier)
|
||||
|
||||
|
||||
@router.post("/checkout", response_model=CheckoutResponse)
|
||||
def create_checkout_session(
|
||||
request: CheckoutRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a Stripe checkout session for subscription."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
vendor = billing_service.get_vendor(db, vendor_id)
|
||||
|
||||
# Build URLs
|
||||
base_url = f"https://{settings.platform_domain}"
|
||||
success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true"
|
||||
cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true"
|
||||
|
||||
result = billing_service.create_checkout_session(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
tier_code=request.tier_code,
|
||||
is_annual=request.is_annual,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"])
|
||||
|
||||
|
||||
@router.post("/portal", response_model=PortalResponse)
|
||||
def create_portal_session(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a Stripe customer portal session."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
vendor = billing_service.get_vendor(db, vendor_id)
|
||||
return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing"
|
||||
|
||||
result = billing_service.create_portal_session(db, vendor_id, return_url)
|
||||
|
||||
return PortalResponse(portal_url=result["portal_url"])
|
||||
|
||||
|
||||
@router.get("/invoices", response_model=InvoiceListResponse)
|
||||
def get_invoices(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get invoice history."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit)
|
||||
|
||||
invoice_responses = [
|
||||
InvoiceResponse(
|
||||
id=inv.id,
|
||||
invoice_number=inv.invoice_number,
|
||||
invoice_date=inv.invoice_date.isoformat(),
|
||||
due_date=inv.due_date.isoformat() if inv.due_date else None,
|
||||
total_cents=inv.total_cents,
|
||||
amount_paid_cents=inv.amount_paid_cents,
|
||||
currency=inv.currency,
|
||||
status=inv.status,
|
||||
pdf_url=inv.invoice_pdf_url,
|
||||
hosted_url=inv.hosted_invoice_url,
|
||||
)
|
||||
for inv in invoices
|
||||
]
|
||||
|
||||
return InvoiceListResponse(invoices=invoice_responses, total=total)
|
||||
|
||||
|
||||
@router.get("/addons", response_model=list[AddOnResponse])
|
||||
def get_available_addons(
|
||||
category: str | None = Query(None, description="Filter by category"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get available add-on products."""
|
||||
addons = billing_service.get_available_addons(db, category=category)
|
||||
|
||||
return [
|
||||
AddOnResponse(
|
||||
id=addon.id,
|
||||
code=addon.code,
|
||||
name=addon.name,
|
||||
description=addon.description,
|
||||
category=addon.category,
|
||||
price_cents=addon.price_cents,
|
||||
billing_period=addon.billing_period,
|
||||
quantity_unit=addon.quantity_unit,
|
||||
quantity_value=addon.quantity_value,
|
||||
)
|
||||
for addon in addons
|
||||
]
|
||||
|
||||
|
||||
@router.get("/my-addons", response_model=list[VendorAddOnResponse])
|
||||
def get_vendor_addons(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get vendor's purchased add-ons."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
vendor_addons = billing_service.get_vendor_addons(db, vendor_id)
|
||||
|
||||
return [
|
||||
VendorAddOnResponse(
|
||||
id=va.id,
|
||||
addon_code=va.addon_product.code,
|
||||
addon_name=va.addon_product.name,
|
||||
status=va.status,
|
||||
domain_name=va.domain_name,
|
||||
quantity=va.quantity,
|
||||
period_start=va.period_start.isoformat() if va.period_start else None,
|
||||
period_end=va.period_end.isoformat() if va.period_end else None,
|
||||
)
|
||||
for va in vendor_addons
|
||||
]
|
||||
|
||||
|
||||
@router.post("/cancel", response_model=CancelResponse)
|
||||
def cancel_subscription(
|
||||
request: CancelRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Cancel subscription."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
result = billing_service.cancel_subscription(
|
||||
db=db,
|
||||
vendor_id=vendor_id,
|
||||
reason=request.reason,
|
||||
immediately=request.immediately,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return CancelResponse(
|
||||
message=result["message"],
|
||||
effective_date=result["effective_date"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reactivate")
|
||||
def reactivate_subscription(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reactivate a cancelled subscription."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
result = billing_service.reactivate_subscription(db, vendor_id)
|
||||
db.commit()
|
||||
|
||||
return result
|
||||
7
app/api/v1/vendor/products.py
vendored
7
app/api/v1/vendor/products.py
vendored
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_service
|
||||
from app.services.subscription_service import subscription_service
|
||||
from models.database.user import User
|
||||
from models.schema.product import (
|
||||
ProductCreate,
|
||||
@@ -89,6 +90,9 @@ def add_product_to_catalog(
|
||||
|
||||
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||
"""
|
||||
# Check product limit before creating
|
||||
subscription_service.check_product_limit(db, current_user.token_vendor_id)
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=current_user.token_vendor_id, product_data=product_data
|
||||
)
|
||||
@@ -157,6 +161,9 @@ def publish_from_marketplace(
|
||||
|
||||
Shortcut endpoint for publishing directly from marketplace import.
|
||||
"""
|
||||
# Check product limit before creating
|
||||
subscription_service.check_product_limit(db, current_user.token_vendor_id)
|
||||
|
||||
product_data = ProductCreate(
|
||||
marketplace_product_id=marketplace_product_id, is_active=True
|
||||
)
|
||||
|
||||
@@ -117,6 +117,14 @@ class Settings(BaseSettings):
|
||||
max_team_members_per_vendor: int = 50
|
||||
invitation_expiry_days: int = 7
|
||||
|
||||
# =============================================================================
|
||||
# STRIPE BILLING
|
||||
# =============================================================================
|
||||
stripe_secret_key: str = ""
|
||||
stripe_publishable_key: str = ""
|
||||
stripe_webhook_secret: str = ""
|
||||
stripe_trial_days: int = 14
|
||||
|
||||
# =============================================================================
|
||||
# DEMO/SEED DATA CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
@@ -46,6 +46,18 @@ from .base import (
|
||||
WizamartException,
|
||||
)
|
||||
|
||||
# Billing exceptions
|
||||
from .billing import (
|
||||
InvalidWebhookSignatureException,
|
||||
NoActiveSubscriptionException,
|
||||
PaymentSystemNotConfiguredException,
|
||||
StripePriceNotConfiguredException,
|
||||
SubscriptionAlreadyCancelledException,
|
||||
SubscriptionNotCancelledException,
|
||||
TierNotFoundException,
|
||||
WebhookMissingSignatureException,
|
||||
)
|
||||
|
||||
# Cart exceptions
|
||||
from .cart import (
|
||||
CartItemNotFoundException,
|
||||
@@ -416,4 +428,13 @@ __all__ = [
|
||||
"InvalidConversationTypeException",
|
||||
"InvalidRecipientTypeException",
|
||||
"AttachmentNotFoundException",
|
||||
# Billing exceptions
|
||||
"PaymentSystemNotConfiguredException",
|
||||
"TierNotFoundException",
|
||||
"StripePriceNotConfiguredException",
|
||||
"NoActiveSubscriptionException",
|
||||
"SubscriptionNotCancelledException",
|
||||
"SubscriptionAlreadyCancelledException",
|
||||
"InvalidWebhookSignatureException",
|
||||
"WebhookMissingSignatureException",
|
||||
]
|
||||
|
||||
95
app/exceptions/billing.py
Normal file
95
app/exceptions/billing.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# app/exceptions/billing.py
|
||||
"""
|
||||
Billing and subscription related exceptions.
|
||||
|
||||
This module provides exceptions for:
|
||||
- Payment system configuration issues
|
||||
- Subscription management errors
|
||||
- Tier-related errors
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import BusinessLogicException, ResourceNotFoundException, ServiceUnavailableException
|
||||
|
||||
|
||||
class PaymentSystemNotConfiguredException(ServiceUnavailableException):
|
||||
"""Raised when the payment system (Stripe) is not configured."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(message="Payment system not configured")
|
||||
|
||||
|
||||
class TierNotFoundException(ResourceNotFoundException):
|
||||
"""Raised when a subscription tier is not found."""
|
||||
|
||||
def __init__(self, tier_code: str):
|
||||
super().__init__(
|
||||
resource_type="SubscriptionTier",
|
||||
identifier=tier_code,
|
||||
message=f"Subscription tier '{tier_code}' not found",
|
||||
error_code="TIER_NOT_FOUND",
|
||||
)
|
||||
self.tier_code = tier_code
|
||||
|
||||
|
||||
class StripePriceNotConfiguredException(BusinessLogicException):
|
||||
"""Raised when Stripe price is not configured for a tier."""
|
||||
|
||||
def __init__(self, tier_code: str):
|
||||
super().__init__(
|
||||
message=f"Stripe price not configured for tier '{tier_code}'",
|
||||
error_code="STRIPE_PRICE_NOT_CONFIGURED",
|
||||
details={"tier_code": tier_code},
|
||||
)
|
||||
self.tier_code = tier_code
|
||||
|
||||
|
||||
class NoActiveSubscriptionException(BusinessLogicException):
|
||||
"""Raised when no active subscription exists for an operation that requires one."""
|
||||
|
||||
def __init__(self, message: str = "No active subscription found"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="NO_ACTIVE_SUBSCRIPTION",
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionNotCancelledException(BusinessLogicException):
|
||||
"""Raised when trying to reactivate a subscription that is not cancelled."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Subscription is not cancelled",
|
||||
error_code="SUBSCRIPTION_NOT_CANCELLED",
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionAlreadyCancelledException(BusinessLogicException):
|
||||
"""Raised when trying to cancel an already cancelled subscription."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Subscription is already cancelled",
|
||||
error_code="SUBSCRIPTION_ALREADY_CANCELLED",
|
||||
)
|
||||
|
||||
|
||||
class InvalidWebhookSignatureException(BusinessLogicException):
|
||||
"""Raised when Stripe webhook signature verification fails."""
|
||||
|
||||
def __init__(self, message: str = "Invalid webhook signature"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
error_code="INVALID_WEBHOOK_SIGNATURE",
|
||||
)
|
||||
|
||||
|
||||
class WebhookMissingSignatureException(BusinessLogicException):
|
||||
"""Raised when Stripe webhook is missing the signature header."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
message="Missing Stripe-Signature header",
|
||||
error_code="WEBHOOK_MISSING_SIGNATURE",
|
||||
)
|
||||
@@ -454,6 +454,28 @@ async def vendor_settings_page(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vendor_code}/billing", response_class=HTMLResponse, include_in_schema=False
|
||||
)
|
||||
async def vendor_billing_page(
|
||||
request: Request,
|
||||
vendor_code: str = Path(..., description="Vendor code"),
|
||||
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
|
||||
):
|
||||
"""
|
||||
Render billing and subscription management page.
|
||||
JavaScript loads subscription status, tiers, and invoices via API.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"vendor/billing.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": current_user,
|
||||
"vendor_code": vendor_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DYNAMIC CONTENT PAGES (CMS)
|
||||
# ============================================================================
|
||||
|
||||
370
app/services/billing_service.py
Normal file
370
app/services/billing_service.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# app/services/billing_service.py
|
||||
"""
|
||||
Billing service for subscription and payment operations.
|
||||
|
||||
Provides:
|
||||
- Subscription status and usage queries
|
||||
- Tier management
|
||||
- Invoice history
|
||||
- Add-on management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.stripe_service import stripe_service
|
||||
from app.services.subscription_service import subscription_service
|
||||
from models.database.subscription import (
|
||||
AddOnProduct,
|
||||
BillingHistory,
|
||||
SubscriptionTier,
|
||||
VendorAddOn,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingServiceError(Exception):
|
||||
"""Base exception for billing service errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PaymentSystemNotConfiguredError(BillingServiceError):
|
||||
"""Raised when Stripe is not configured."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("Payment system not configured")
|
||||
|
||||
|
||||
class TierNotFoundError(BillingServiceError):
|
||||
"""Raised when a tier is not found."""
|
||||
|
||||
def __init__(self, tier_code: str):
|
||||
self.tier_code = tier_code
|
||||
super().__init__(f"Tier '{tier_code}' not found")
|
||||
|
||||
|
||||
class StripePriceNotConfiguredError(BillingServiceError):
|
||||
"""Raised when Stripe price is not configured for a tier."""
|
||||
|
||||
def __init__(self, tier_code: str):
|
||||
self.tier_code = tier_code
|
||||
super().__init__(f"Stripe price not configured for tier '{tier_code}'")
|
||||
|
||||
|
||||
class NoActiveSubscriptionError(BillingServiceError):
|
||||
"""Raised when no active subscription exists."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("No active subscription found")
|
||||
|
||||
|
||||
class SubscriptionNotCancelledError(BillingServiceError):
|
||||
"""Raised when trying to reactivate a non-cancelled subscription."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("Subscription is not cancelled")
|
||||
|
||||
|
||||
class BillingService:
|
||||
"""Service for billing operations."""
|
||||
|
||||
def get_subscription_with_tier(
|
||||
self, db: Session, vendor_id: int
|
||||
) -> tuple[VendorSubscription, SubscriptionTier | None]:
|
||||
"""
|
||||
Get subscription and its tier info.
|
||||
|
||||
Returns:
|
||||
Tuple of (subscription, tier) where tier may be None
|
||||
"""
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.code == subscription.tier)
|
||||
.first()
|
||||
)
|
||||
|
||||
return subscription, tier
|
||||
|
||||
def get_available_tiers(
|
||||
self, db: Session, current_tier: str
|
||||
) -> tuple[list[dict], dict[str, int]]:
|
||||
"""
|
||||
Get all available tiers with upgrade/downgrade flags.
|
||||
|
||||
Returns:
|
||||
Tuple of (tier_list, tier_order_map)
|
||||
"""
|
||||
tiers = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(
|
||||
SubscriptionTier.is_active == True, # noqa: E712
|
||||
SubscriptionTier.is_public == True, # noqa: E712
|
||||
)
|
||||
.order_by(SubscriptionTier.display_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
tier_order = {t.code: t.display_order for t in tiers}
|
||||
current_order = tier_order.get(current_tier, 0)
|
||||
|
||||
tier_list = []
|
||||
for tier in tiers:
|
||||
tier_list.append({
|
||||
"code": tier.code,
|
||||
"name": tier.name,
|
||||
"description": tier.description,
|
||||
"price_monthly_cents": tier.price_monthly_cents,
|
||||
"price_annual_cents": tier.price_annual_cents,
|
||||
"orders_per_month": tier.orders_per_month,
|
||||
"products_limit": tier.products_limit,
|
||||
"team_members": tier.team_members,
|
||||
"features": tier.features or [],
|
||||
"is_current": tier.code == current_tier,
|
||||
"can_upgrade": tier.display_order > current_order,
|
||||
"can_downgrade": tier.display_order < current_order,
|
||||
})
|
||||
|
||||
return tier_list, tier_order
|
||||
|
||||
def get_tier_by_code(self, db: Session, tier_code: str) -> SubscriptionTier:
|
||||
"""
|
||||
Get a tier by its code.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
"""
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(
|
||||
SubscriptionTier.code == tier_code,
|
||||
SubscriptionTier.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not tier:
|
||||
raise TierNotFoundError(tier_code)
|
||||
|
||||
return tier
|
||||
|
||||
def get_vendor(self, db: Session, vendor_id: int) -> Vendor:
|
||||
"""
|
||||
Get vendor by ID.
|
||||
|
||||
Raises:
|
||||
VendorNotFoundException from app.exceptions
|
||||
"""
|
||||
from app.exceptions import VendorNotFoundException
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor:
|
||||
raise VendorNotFoundException(str(vendor_id), identifier_type="id")
|
||||
|
||||
return vendor
|
||||
|
||||
def create_checkout_session(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int,
|
||||
tier_code: str,
|
||||
is_annual: bool,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a Stripe checkout session.
|
||||
|
||||
Returns:
|
||||
Dict with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
TierNotFoundError: If tier doesn't exist
|
||||
StripePriceNotConfiguredError: If price not configured
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
|
||||
vendor = self.get_vendor(db, vendor_id)
|
||||
tier = self.get_tier_by_code(db, tier_code)
|
||||
|
||||
price_id = (
|
||||
tier.stripe_price_annual_id
|
||||
if is_annual and tier.stripe_price_annual_id
|
||||
else tier.stripe_price_monthly_id
|
||||
)
|
||||
|
||||
if not price_id:
|
||||
raise StripePriceNotConfiguredError(tier_code)
|
||||
|
||||
# Check if this is a new subscription (for trial)
|
||||
existing_sub = subscription_service.get_subscription(db, vendor_id)
|
||||
trial_days = None
|
||||
if not existing_sub or not existing_sub.stripe_subscription_id:
|
||||
from app.core.config import settings
|
||||
trial_days = settings.stripe_trial_days
|
||||
|
||||
session = stripe_service.create_checkout_session(
|
||||
db=db,
|
||||
vendor=vendor,
|
||||
price_id=price_id,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
trial_days=trial_days,
|
||||
)
|
||||
|
||||
# Update subscription with tier info
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
subscription.tier = tier_code
|
||||
subscription.is_annual = is_annual
|
||||
|
||||
return {
|
||||
"checkout_url": session.url,
|
||||
"session_id": session.id,
|
||||
}
|
||||
|
||||
def create_portal_session(self, db: Session, vendor_id: int, return_url: str) -> dict:
|
||||
"""
|
||||
Create a Stripe customer portal session.
|
||||
|
||||
Returns:
|
||||
Dict with portal_url
|
||||
|
||||
Raises:
|
||||
PaymentSystemNotConfiguredError: If Stripe not configured
|
||||
NoActiveSubscriptionError: If no subscription with customer ID
|
||||
"""
|
||||
if not stripe_service.is_configured:
|
||||
raise PaymentSystemNotConfiguredError()
|
||||
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
if not subscription or not subscription.stripe_customer_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
|
||||
session = stripe_service.create_portal_session(
|
||||
customer_id=subscription.stripe_customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
return {"portal_url": session.url}
|
||||
|
||||
def get_invoices(
|
||||
self, db: Session, vendor_id: int, skip: int = 0, limit: int = 20
|
||||
) -> tuple[list[BillingHistory], int]:
|
||||
"""
|
||||
Get invoice history for a vendor.
|
||||
|
||||
Returns:
|
||||
Tuple of (invoices, total_count)
|
||||
"""
|
||||
query = db.query(BillingHistory).filter(BillingHistory.vendor_id == vendor_id)
|
||||
|
||||
total = query.count()
|
||||
|
||||
invoices = (
|
||||
query.order_by(BillingHistory.invoice_date.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return invoices, total
|
||||
|
||||
def get_available_addons(
|
||||
self, db: Session, category: str | None = None
|
||||
) -> list[AddOnProduct]:
|
||||
"""Get available add-on products."""
|
||||
query = db.query(AddOnProduct).filter(AddOnProduct.is_active == True) # noqa: E712
|
||||
|
||||
if category:
|
||||
query = query.filter(AddOnProduct.category == category)
|
||||
|
||||
return query.order_by(AddOnProduct.display_order).all()
|
||||
|
||||
def get_vendor_addons(self, db: Session, vendor_id: int) -> list[VendorAddOn]:
|
||||
"""Get vendor's purchased add-ons."""
|
||||
return (
|
||||
db.query(VendorAddOn)
|
||||
.filter(VendorAddOn.vendor_id == vendor_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
def cancel_subscription(
|
||||
self, db: Session, vendor_id: int, reason: str | None, immediately: bool
|
||||
) -> dict:
|
||||
"""
|
||||
Cancel a subscription.
|
||||
|
||||
Returns:
|
||||
Dict with message and effective_date
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription to cancel
|
||||
"""
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
|
||||
if stripe_service.is_configured:
|
||||
stripe_service.cancel_subscription(
|
||||
subscription_id=subscription.stripe_subscription_id,
|
||||
immediately=immediately,
|
||||
cancellation_reason=reason,
|
||||
)
|
||||
|
||||
subscription.cancelled_at = datetime.utcnow()
|
||||
subscription.cancellation_reason = reason
|
||||
|
||||
effective_date = (
|
||||
datetime.utcnow().isoformat()
|
||||
if immediately
|
||||
else subscription.period_end.isoformat()
|
||||
if subscription.period_end
|
||||
else datetime.utcnow().isoformat()
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Subscription cancelled successfully",
|
||||
"effective_date": effective_date,
|
||||
}
|
||||
|
||||
def reactivate_subscription(self, db: Session, vendor_id: int) -> dict:
|
||||
"""
|
||||
Reactivate a cancelled subscription.
|
||||
|
||||
Returns:
|
||||
Dict with success message
|
||||
|
||||
Raises:
|
||||
NoActiveSubscriptionError: If no subscription
|
||||
SubscriptionNotCancelledError: If not cancelled
|
||||
"""
|
||||
subscription = subscription_service.get_subscription(db, vendor_id)
|
||||
|
||||
if not subscription or not subscription.stripe_subscription_id:
|
||||
raise NoActiveSubscriptionError()
|
||||
|
||||
if not subscription.cancelled_at:
|
||||
raise SubscriptionNotCancelledError()
|
||||
|
||||
if stripe_service.is_configured:
|
||||
stripe_service.reactivate_subscription(subscription.stripe_subscription_id)
|
||||
|
||||
subscription.cancelled_at = None
|
||||
subscription.cancellation_reason = None
|
||||
|
||||
return {"message": "Subscription reactivated successfully"}
|
||||
|
||||
|
||||
# Create service instance
|
||||
billing_service = BillingService()
|
||||
@@ -865,12 +865,42 @@ class MarketplaceProductService:
|
||||
if not marketplace_products:
|
||||
raise MarketplaceProductNotFoundException("No marketplace products found")
|
||||
|
||||
# Check product limit from subscription
|
||||
from app.services.subscription_service import subscription_service
|
||||
from sqlalchemy import func
|
||||
|
||||
current_products = (
|
||||
db.query(func.count(Product.id))
|
||||
.filter(Product.vendor_id == vendor_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
subscription = subscription_service.get_or_create_subscription(db, vendor_id)
|
||||
products_limit = subscription.products_limit
|
||||
remaining_capacity = (
|
||||
products_limit - current_products if products_limit is not None else None
|
||||
)
|
||||
|
||||
copied = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
limit_reached = False
|
||||
details = []
|
||||
|
||||
for mp in marketplace_products:
|
||||
# Check if we've hit the product limit
|
||||
if remaining_capacity is not None and copied >= remaining_capacity:
|
||||
limit_reached = True
|
||||
details.append(
|
||||
{
|
||||
"id": mp.id,
|
||||
"status": "skipped",
|
||||
"reason": "Product limit reached",
|
||||
}
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
existing = (
|
||||
db.query(Product)
|
||||
@@ -994,6 +1024,7 @@ class MarketplaceProductService:
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"auto_matched": auto_matched,
|
||||
"limit_reached": limit_reached,
|
||||
"details": details if len(details) <= 100 else None,
|
||||
}
|
||||
|
||||
|
||||
459
app/services/stripe_service.py
Normal file
459
app/services/stripe_service.py
Normal file
@@ -0,0 +1,459 @@
|
||||
# app/services/stripe_service.py
|
||||
"""
|
||||
Stripe payment integration service.
|
||||
|
||||
Provides:
|
||||
- Customer management
|
||||
- Subscription management
|
||||
- Checkout session creation
|
||||
- Customer portal access
|
||||
- Webhook event construction
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import stripe
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from models.database.subscription import (
|
||||
BillingHistory,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
VendorSubscription,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StripeService:
|
||||
"""Service for Stripe payment operations."""
|
||||
|
||||
def __init__(self):
|
||||
self._configured = False
|
||||
self._configure()
|
||||
|
||||
def _configure(self):
|
||||
"""Configure Stripe with API key."""
|
||||
if settings.stripe_secret_key:
|
||||
stripe.api_key = settings.stripe_secret_key
|
||||
self._configured = True
|
||||
else:
|
||||
logger.warning("Stripe API key not configured")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Stripe is properly configured."""
|
||||
return self._configured and bool(settings.stripe_secret_key)
|
||||
|
||||
# =========================================================================
|
||||
# Customer Management
|
||||
# =========================================================================
|
||||
|
||||
def create_customer(
|
||||
self,
|
||||
vendor: Vendor,
|
||||
email: str,
|
||||
name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a Stripe customer for a vendor.
|
||||
|
||||
Returns the Stripe customer ID.
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
customer_metadata = {
|
||||
"vendor_id": str(vendor.id),
|
||||
"vendor_code": vendor.vendor_code,
|
||||
**(metadata or {}),
|
||||
}
|
||||
|
||||
customer = stripe.Customer.create(
|
||||
email=email,
|
||||
name=name or vendor.name,
|
||||
metadata=customer_metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe customer {customer.id} for vendor {vendor.vendor_code}"
|
||||
)
|
||||
return customer.id
|
||||
|
||||
def get_customer(self, customer_id: str) -> stripe.Customer:
|
||||
"""Get a Stripe customer by ID."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
return stripe.Customer.retrieve(customer_id)
|
||||
|
||||
def update_customer(
|
||||
self,
|
||||
customer_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> stripe.Customer:
|
||||
"""Update a Stripe customer."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
update_data = {}
|
||||
if email:
|
||||
update_data["email"] = email
|
||||
if name:
|
||||
update_data["name"] = name
|
||||
if metadata:
|
||||
update_data["metadata"] = metadata
|
||||
|
||||
return stripe.Customer.modify(customer_id, **update_data)
|
||||
|
||||
# =========================================================================
|
||||
# Subscription Management
|
||||
# =========================================================================
|
||||
|
||||
def create_subscription(
|
||||
self,
|
||||
customer_id: str,
|
||||
price_id: str,
|
||||
trial_days: int | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> stripe.Subscription:
|
||||
"""
|
||||
Create a new Stripe subscription.
|
||||
|
||||
Args:
|
||||
customer_id: Stripe customer ID
|
||||
price_id: Stripe price ID for the subscription
|
||||
trial_days: Optional trial period in days
|
||||
metadata: Optional metadata to attach
|
||||
|
||||
Returns:
|
||||
Stripe Subscription object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
subscription_data = {
|
||||
"customer": customer_id,
|
||||
"items": [{"price": price_id}],
|
||||
"metadata": metadata or {},
|
||||
"payment_behavior": "default_incomplete",
|
||||
"expand": ["latest_invoice.payment_intent"],
|
||||
}
|
||||
|
||||
if trial_days:
|
||||
subscription_data["trial_period_days"] = trial_days
|
||||
|
||||
subscription = stripe.Subscription.create(**subscription_data)
|
||||
logger.info(
|
||||
f"Created Stripe subscription {subscription.id} for customer {customer_id}"
|
||||
)
|
||||
return subscription
|
||||
|
||||
def get_subscription(self, subscription_id: str) -> stripe.Subscription:
|
||||
"""Get a Stripe subscription by ID."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
return stripe.Subscription.retrieve(subscription_id)
|
||||
|
||||
def update_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
new_price_id: str | None = None,
|
||||
proration_behavior: str = "create_prorations",
|
||||
metadata: dict | None = None,
|
||||
) -> stripe.Subscription:
|
||||
"""
|
||||
Update a Stripe subscription (e.g., change tier).
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
new_price_id: New price ID for tier change
|
||||
proration_behavior: How to handle prorations
|
||||
metadata: Optional metadata to update
|
||||
|
||||
Returns:
|
||||
Updated Stripe Subscription object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
update_data = {"proration_behavior": proration_behavior}
|
||||
|
||||
if new_price_id:
|
||||
# Get the subscription to find the item ID
|
||||
subscription = stripe.Subscription.retrieve(subscription_id)
|
||||
item_id = subscription["items"]["data"][0]["id"]
|
||||
update_data["items"] = [{"id": item_id, "price": new_price_id}]
|
||||
|
||||
if metadata:
|
||||
update_data["metadata"] = metadata
|
||||
|
||||
updated = stripe.Subscription.modify(subscription_id, **update_data)
|
||||
logger.info(f"Updated Stripe subscription {subscription_id}")
|
||||
return updated
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
immediately: bool = False,
|
||||
cancellation_reason: str | None = None,
|
||||
) -> stripe.Subscription:
|
||||
"""
|
||||
Cancel a Stripe subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
immediately: If True, cancel now. If False, cancel at period end.
|
||||
cancellation_reason: Optional reason for cancellation
|
||||
|
||||
Returns:
|
||||
Cancelled Stripe Subscription object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
if immediately:
|
||||
subscription = stripe.Subscription.cancel(subscription_id)
|
||||
else:
|
||||
subscription = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
cancel_at_period_end=True,
|
||||
metadata={"cancellation_reason": cancellation_reason or "user_request"},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Cancelled Stripe subscription {subscription_id} "
|
||||
f"(immediately={immediately})"
|
||||
)
|
||||
return subscription
|
||||
|
||||
def reactivate_subscription(self, subscription_id: str) -> stripe.Subscription:
|
||||
"""
|
||||
Reactivate a cancelled subscription (if not yet ended).
|
||||
|
||||
Returns:
|
||||
Reactivated Stripe Subscription object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
subscription = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
cancel_at_period_end=False,
|
||||
)
|
||||
logger.info(f"Reactivated Stripe subscription {subscription_id}")
|
||||
return subscription
|
||||
|
||||
# =========================================================================
|
||||
# Checkout & Portal
|
||||
# =========================================================================
|
||||
|
||||
def create_checkout_session(
|
||||
self,
|
||||
db: Session,
|
||||
vendor: Vendor,
|
||||
price_id: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
trial_days: int | None = None,
|
||||
) -> stripe.checkout.Session:
|
||||
"""
|
||||
Create a Stripe Checkout session for subscription signup.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor: Vendor to create checkout for
|
||||
price_id: Stripe price ID
|
||||
success_url: URL to redirect on success
|
||||
cancel_url: URL to redirect on cancel
|
||||
trial_days: Optional trial period
|
||||
|
||||
Returns:
|
||||
Stripe Checkout Session object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
# Get or create Stripe customer
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.vendor_id == vendor.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription and subscription.stripe_customer_id:
|
||||
customer_id = subscription.stripe_customer_id
|
||||
else:
|
||||
# Get vendor owner email
|
||||
from models.database.vendor import VendorUser
|
||||
|
||||
owner = (
|
||||
db.query(VendorUser)
|
||||
.filter(
|
||||
VendorUser.vendor_id == vendor.id,
|
||||
VendorUser.is_owner == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
email = owner.user.email if owner and owner.user else None
|
||||
|
||||
customer_id = self.create_customer(vendor, email or f"{vendor.vendor_code}@placeholder.com")
|
||||
|
||||
# Store the customer ID
|
||||
if subscription:
|
||||
subscription.stripe_customer_id = customer_id
|
||||
db.flush()
|
||||
|
||||
session_data = {
|
||||
"customer": customer_id,
|
||||
"line_items": [{"price": price_id, "quantity": 1}],
|
||||
"mode": "subscription",
|
||||
"success_url": success_url,
|
||||
"cancel_url": cancel_url,
|
||||
"metadata": {
|
||||
"vendor_id": str(vendor.id),
|
||||
"vendor_code": vendor.vendor_code,
|
||||
},
|
||||
}
|
||||
|
||||
if trial_days:
|
||||
session_data["subscription_data"] = {"trial_period_days": trial_days}
|
||||
|
||||
session = stripe.checkout.Session.create(**session_data)
|
||||
logger.info(f"Created checkout session {session.id} for vendor {vendor.vendor_code}")
|
||||
return session
|
||||
|
||||
def create_portal_session(
|
||||
self,
|
||||
customer_id: str,
|
||||
return_url: str,
|
||||
) -> stripe.billing_portal.Session:
|
||||
"""
|
||||
Create a Stripe Customer Portal session.
|
||||
|
||||
Allows customers to manage their subscription, payment methods, and invoices.
|
||||
|
||||
Args:
|
||||
customer_id: Stripe customer ID
|
||||
return_url: URL to return to after portal
|
||||
|
||||
Returns:
|
||||
Stripe Portal Session object
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
logger.info(f"Created portal session for customer {customer_id}")
|
||||
return session
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Management
|
||||
# =========================================================================
|
||||
|
||||
def get_invoices(
|
||||
self,
|
||||
customer_id: str,
|
||||
limit: int = 10,
|
||||
) -> list[stripe.Invoice]:
|
||||
"""Get invoices for a customer."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
invoices = stripe.Invoice.list(customer=customer_id, limit=limit)
|
||||
return list(invoices.data)
|
||||
|
||||
def get_upcoming_invoice(self, customer_id: str) -> stripe.Invoice | None:
|
||||
"""Get the upcoming invoice for a customer."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
try:
|
||||
return stripe.Invoice.upcoming(customer=customer_id)
|
||||
except stripe.error.InvalidRequestError:
|
||||
# No upcoming invoice
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Webhook Handling
|
||||
# =========================================================================
|
||||
|
||||
def construct_event(
|
||||
self,
|
||||
payload: bytes,
|
||||
sig_header: str,
|
||||
) -> stripe.Event:
|
||||
"""
|
||||
Construct and verify a Stripe webhook event.
|
||||
|
||||
Args:
|
||||
payload: Raw request body
|
||||
sig_header: Stripe-Signature header
|
||||
|
||||
Returns:
|
||||
Verified Stripe Event object
|
||||
|
||||
Raises:
|
||||
ValueError: If signature verification fails
|
||||
"""
|
||||
if not settings.stripe_webhook_secret:
|
||||
raise ValueError("Stripe webhook secret not configured")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload,
|
||||
sig_header,
|
||||
settings.stripe_webhook_secret,
|
||||
)
|
||||
return event
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error(f"Webhook signature verification failed: {e}")
|
||||
raise ValueError("Invalid webhook signature")
|
||||
|
||||
# =========================================================================
|
||||
# Price/Product Management
|
||||
# =========================================================================
|
||||
|
||||
def get_price(self, price_id: str) -> stripe.Price:
|
||||
"""Get a Stripe price by ID."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
return stripe.Price.retrieve(price_id)
|
||||
|
||||
def get_product(self, product_id: str) -> stripe.Product:
|
||||
"""Get a Stripe product by ID."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
return stripe.Product.retrieve(product_id)
|
||||
|
||||
def list_prices(
|
||||
self,
|
||||
product_id: str | None = None,
|
||||
active: bool = True,
|
||||
) -> list[stripe.Price]:
|
||||
"""List Stripe prices, optionally filtered by product."""
|
||||
if not self.is_configured:
|
||||
raise ValueError("Stripe is not configured")
|
||||
|
||||
params = {"active": active}
|
||||
if product_id:
|
||||
params["product"] = product_id
|
||||
|
||||
prices = stripe.Price.list(**params)
|
||||
return list(prices.data)
|
||||
|
||||
|
||||
# Create service instance
|
||||
stripe_service = StripeService()
|
||||
411
app/services/stripe_webhook_handler.py
Normal file
411
app/services/stripe_webhook_handler.py
Normal file
@@ -0,0 +1,411 @@
|
||||
# app/services/stripe_webhook_handler.py
|
||||
"""
|
||||
Stripe webhook event handler.
|
||||
|
||||
Processes webhook events from Stripe:
|
||||
- Subscription lifecycle events
|
||||
- Invoice and payment events
|
||||
- Checkout session completion
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import stripe
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.subscription import (
|
||||
BillingHistory,
|
||||
StripeWebhookEvent,
|
||||
SubscriptionStatus,
|
||||
SubscriptionTier,
|
||||
VendorSubscription,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StripeWebhookHandler:
|
||||
"""Handler for Stripe webhook events."""
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = {
|
||||
"checkout.session.completed": self._handle_checkout_completed,
|
||||
"customer.subscription.created": self._handle_subscription_created,
|
||||
"customer.subscription.updated": self._handle_subscription_updated,
|
||||
"customer.subscription.deleted": self._handle_subscription_deleted,
|
||||
"invoice.paid": self._handle_invoice_paid,
|
||||
"invoice.payment_failed": self._handle_payment_failed,
|
||||
"invoice.finalized": self._handle_invoice_finalized,
|
||||
}
|
||||
|
||||
def handle_event(self, db: Session, event: stripe.Event) -> dict:
|
||||
"""
|
||||
Process a Stripe webhook event.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event: Stripe Event object
|
||||
|
||||
Returns:
|
||||
Dict with processing result
|
||||
"""
|
||||
event_id = event.id
|
||||
event_type = event.type
|
||||
|
||||
# Check for duplicate processing (idempotency)
|
||||
existing = (
|
||||
db.query(StripeWebhookEvent)
|
||||
.filter(StripeWebhookEvent.event_id == event_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
if existing.status == "processed":
|
||||
logger.info(f"Skipping duplicate event {event_id}")
|
||||
return {"status": "skipped", "reason": "duplicate"}
|
||||
elif existing.status == "failed":
|
||||
logger.info(f"Retrying previously failed event {event_id}")
|
||||
else:
|
||||
# Record the event
|
||||
webhook_event = StripeWebhookEvent(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
status="pending",
|
||||
)
|
||||
db.add(webhook_event)
|
||||
db.flush()
|
||||
existing = webhook_event
|
||||
|
||||
# Process the event
|
||||
handler = self.handlers.get(event_type)
|
||||
if not handler:
|
||||
logger.debug(f"No handler for event type {event_type}")
|
||||
existing.status = "processed"
|
||||
existing.processed_at = datetime.now(timezone.utc)
|
||||
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
|
||||
return {"status": "ignored", "reason": f"no handler for {event_type}"}
|
||||
|
||||
try:
|
||||
result = handler(db, event)
|
||||
existing.status = "processed"
|
||||
existing.processed_at = datetime.now(timezone.utc)
|
||||
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
|
||||
logger.info(f"Successfully processed event {event_id} ({event_type})")
|
||||
return {"status": "processed", "result": result}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
existing.status = "failed"
|
||||
existing.error_message = str(e)
|
||||
db.commit() # noqa: SVC-006 - Webhook handler controls its own transaction
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# Event Handlers
|
||||
# =========================================================================
|
||||
|
||||
def _handle_checkout_completed(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle checkout.session.completed event."""
|
||||
session = event.data.object
|
||||
vendor_id = session.metadata.get("vendor_id")
|
||||
|
||||
if not vendor_id:
|
||||
logger.warning(f"Checkout session {session.id} missing vendor_id")
|
||||
return {"action": "skipped", "reason": "no vendor_id"}
|
||||
|
||||
vendor_id = int(vendor_id)
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for vendor {vendor_id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Update subscription with Stripe IDs
|
||||
subscription.stripe_customer_id = session.customer
|
||||
subscription.stripe_subscription_id = session.subscription
|
||||
subscription.status = SubscriptionStatus.ACTIVE
|
||||
|
||||
# Get subscription details to set period dates
|
||||
if session.subscription:
|
||||
stripe_sub = stripe.Subscription.retrieve(session.subscription)
|
||||
subscription.period_start = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_start, tz=timezone.utc
|
||||
)
|
||||
subscription.period_end = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_end, tz=timezone.utc
|
||||
)
|
||||
|
||||
if stripe_sub.trial_end:
|
||||
subscription.trial_ends_at = datetime.fromtimestamp(
|
||||
stripe_sub.trial_end, tz=timezone.utc
|
||||
)
|
||||
|
||||
logger.info(f"Checkout completed for vendor {vendor_id}")
|
||||
return {"action": "activated", "vendor_id": vendor_id}
|
||||
|
||||
def _handle_subscription_created(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle customer.subscription.created event."""
|
||||
stripe_sub = event.data.object
|
||||
customer_id = stripe_sub.customer
|
||||
|
||||
# Find subscription by customer ID
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_customer_id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for customer {customer_id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Update subscription
|
||||
subscription.stripe_subscription_id = stripe_sub.id
|
||||
subscription.status = self._map_stripe_status(stripe_sub.status)
|
||||
subscription.period_start = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_start, tz=timezone.utc
|
||||
)
|
||||
subscription.period_end = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_end, tz=timezone.utc
|
||||
)
|
||||
|
||||
logger.info(f"Subscription created for vendor {subscription.vendor_id}")
|
||||
return {"action": "created", "vendor_id": subscription.vendor_id}
|
||||
|
||||
def _handle_subscription_updated(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle customer.subscription.updated event."""
|
||||
stripe_sub = event.data.object
|
||||
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_subscription_id == stripe_sub.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for {stripe_sub.id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Update status and period
|
||||
subscription.status = self._map_stripe_status(stripe_sub.status)
|
||||
subscription.period_start = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_start, tz=timezone.utc
|
||||
)
|
||||
subscription.period_end = datetime.fromtimestamp(
|
||||
stripe_sub.current_period_end, tz=timezone.utc
|
||||
)
|
||||
|
||||
# Handle cancellation
|
||||
if stripe_sub.cancel_at_period_end:
|
||||
subscription.cancelled_at = datetime.now(timezone.utc)
|
||||
subscription.cancellation_reason = stripe_sub.metadata.get(
|
||||
"cancellation_reason", "user_request"
|
||||
)
|
||||
elif subscription.cancelled_at and not stripe_sub.cancel_at_period_end:
|
||||
# Subscription reactivated
|
||||
subscription.cancelled_at = None
|
||||
subscription.cancellation_reason = None
|
||||
|
||||
# Check for tier change via price
|
||||
if stripe_sub.items.data:
|
||||
new_price_id = stripe_sub.items.data[0].price.id
|
||||
if subscription.stripe_price_id != new_price_id:
|
||||
# Price changed, look up new tier
|
||||
tier = (
|
||||
db.query(SubscriptionTier)
|
||||
.filter(SubscriptionTier.stripe_price_monthly_id == new_price_id)
|
||||
.first()
|
||||
)
|
||||
if tier:
|
||||
subscription.tier = tier.code
|
||||
logger.info(
|
||||
f"Tier changed to {tier.code} for vendor {subscription.vendor_id}"
|
||||
)
|
||||
subscription.stripe_price_id = new_price_id
|
||||
|
||||
logger.info(f"Subscription updated for vendor {subscription.vendor_id}")
|
||||
return {"action": "updated", "vendor_id": subscription.vendor_id}
|
||||
|
||||
def _handle_subscription_deleted(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle customer.subscription.deleted event."""
|
||||
stripe_sub = event.data.object
|
||||
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_subscription_id == stripe_sub.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for {stripe_sub.id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
subscription.status = SubscriptionStatus.CANCELLED
|
||||
subscription.cancelled_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(f"Subscription deleted for vendor {subscription.vendor_id}")
|
||||
return {"action": "cancelled", "vendor_id": subscription.vendor_id}
|
||||
|
||||
def _handle_invoice_paid(self, db: Session, event: stripe.Event) -> dict:
|
||||
"""Handle invoice.paid event."""
|
||||
invoice = event.data.object
|
||||
customer_id = invoice.customer
|
||||
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_customer_id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for customer {customer_id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Record billing history
|
||||
billing_record = BillingHistory(
|
||||
vendor_id=subscription.vendor_id,
|
||||
stripe_invoice_id=invoice.id,
|
||||
stripe_payment_intent_id=invoice.payment_intent,
|
||||
invoice_number=invoice.number,
|
||||
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc),
|
||||
subtotal_cents=invoice.subtotal,
|
||||
tax_cents=invoice.tax or 0,
|
||||
total_cents=invoice.total,
|
||||
amount_paid_cents=invoice.amount_paid,
|
||||
currency=invoice.currency.upper(),
|
||||
status="paid",
|
||||
invoice_pdf_url=invoice.invoice_pdf,
|
||||
hosted_invoice_url=invoice.hosted_invoice_url,
|
||||
)
|
||||
db.add(billing_record)
|
||||
|
||||
# Reset payment retry count on successful payment
|
||||
subscription.payment_retry_count = 0
|
||||
subscription.last_payment_error = None
|
||||
|
||||
# Reset period counters if this is a new billing cycle
|
||||
if subscription.status == SubscriptionStatus.ACTIVE:
|
||||
subscription.orders_this_period = 0
|
||||
subscription.orders_limit_reached_at = None
|
||||
|
||||
logger.info(f"Invoice paid for vendor {subscription.vendor_id}")
|
||||
return {
|
||||
"action": "recorded",
|
||||
"vendor_id": subscription.vendor_id,
|
||||
"invoice_id": invoice.id,
|
||||
}
|
||||
|
||||
def _handle_payment_failed(self, db: Session, event: stripe.Event) -> dict:
|
||||
"""Handle invoice.payment_failed event."""
|
||||
invoice = event.data.object
|
||||
customer_id = invoice.customer
|
||||
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_customer_id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
logger.warning(f"No subscription found for customer {customer_id}")
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Update subscription status
|
||||
subscription.status = SubscriptionStatus.PAST_DUE
|
||||
subscription.payment_retry_count = (subscription.payment_retry_count or 0) + 1
|
||||
|
||||
# Store error message
|
||||
if invoice.last_payment_error:
|
||||
subscription.last_payment_error = invoice.last_payment_error.get("message")
|
||||
|
||||
logger.warning(
|
||||
f"Payment failed for vendor {subscription.vendor_id} "
|
||||
f"(retry #{subscription.payment_retry_count})"
|
||||
)
|
||||
return {
|
||||
"action": "marked_past_due",
|
||||
"vendor_id": subscription.vendor_id,
|
||||
"retry_count": subscription.payment_retry_count,
|
||||
}
|
||||
|
||||
def _handle_invoice_finalized(
|
||||
self, db: Session, event: stripe.Event
|
||||
) -> dict:
|
||||
"""Handle invoice.finalized event."""
|
||||
invoice = event.data.object
|
||||
customer_id = invoice.customer
|
||||
|
||||
subscription = (
|
||||
db.query(VendorSubscription)
|
||||
.filter(VendorSubscription.stripe_customer_id == customer_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
return {"action": "skipped", "reason": "no subscription"}
|
||||
|
||||
# Check if we already have this invoice
|
||||
existing = (
|
||||
db.query(BillingHistory)
|
||||
.filter(BillingHistory.stripe_invoice_id == invoice.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
return {"action": "skipped", "reason": "already recorded"}
|
||||
|
||||
# Record as pending invoice
|
||||
billing_record = BillingHistory(
|
||||
vendor_id=subscription.vendor_id,
|
||||
stripe_invoice_id=invoice.id,
|
||||
invoice_number=invoice.number,
|
||||
invoice_date=datetime.fromtimestamp(invoice.created, tz=timezone.utc),
|
||||
due_date=datetime.fromtimestamp(invoice.due_date, tz=timezone.utc)
|
||||
if invoice.due_date
|
||||
else None,
|
||||
subtotal_cents=invoice.subtotal,
|
||||
tax_cents=invoice.tax or 0,
|
||||
total_cents=invoice.total,
|
||||
amount_paid_cents=0,
|
||||
currency=invoice.currency.upper(),
|
||||
status="open",
|
||||
invoice_pdf_url=invoice.invoice_pdf,
|
||||
hosted_invoice_url=invoice.hosted_invoice_url,
|
||||
)
|
||||
db.add(billing_record)
|
||||
|
||||
return {"action": "recorded_pending", "vendor_id": subscription.vendor_id}
|
||||
|
||||
# =========================================================================
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def _map_stripe_status(self, stripe_status: str) -> SubscriptionStatus:
|
||||
"""Map Stripe subscription status to internal status."""
|
||||
status_map = {
|
||||
"active": SubscriptionStatus.ACTIVE,
|
||||
"trialing": SubscriptionStatus.TRIAL,
|
||||
"past_due": SubscriptionStatus.PAST_DUE,
|
||||
"canceled": SubscriptionStatus.CANCELLED,
|
||||
"unpaid": SubscriptionStatus.PAST_DUE,
|
||||
"incomplete": SubscriptionStatus.TRIAL, # Treat as trial until complete
|
||||
"incomplete_expired": SubscriptionStatus.EXPIRED,
|
||||
}
|
||||
return status_map.get(stripe_status, SubscriptionStatus.EXPIRED)
|
||||
|
||||
|
||||
# Create handler instance
|
||||
stripe_webhook_handler = StripeWebhookHandler()
|
||||
@@ -20,11 +20,11 @@ from app.core.permissions import get_preset_permissions
|
||||
from app.exceptions import (
|
||||
CannotRemoveOwnerException,
|
||||
InvalidInvitationTokenException,
|
||||
MaxTeamMembersReachedException,
|
||||
TeamInvitationAlreadyAcceptedException,
|
||||
TeamMemberAlreadyExistsException,
|
||||
UserNotFoundException,
|
||||
)
|
||||
from app.services.subscription_service import TierLimitExceededException
|
||||
from middleware.auth import AuthManager
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Role, Vendor, VendorUser, VendorUserType
|
||||
@@ -37,7 +37,6 @@ class VendorTeamService:
|
||||
|
||||
def __init__(self):
|
||||
self.auth_manager = AuthManager()
|
||||
self.max_team_members = 50 # Configure as needed
|
||||
|
||||
def invite_team_member(
|
||||
self,
|
||||
@@ -68,21 +67,10 @@ class VendorTeamService:
|
||||
Dict with invitation details
|
||||
"""
|
||||
try:
|
||||
# Check team size limit
|
||||
current_team_size = (
|
||||
db.query(VendorUser)
|
||||
.filter(
|
||||
VendorUser.vendor_id == vendor.id,
|
||||
VendorUser.is_active == True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
# Check team size limit from subscription
|
||||
from app.services.subscription_service import subscription_service
|
||||
|
||||
if current_team_size >= self.max_team_members:
|
||||
raise MaxTeamMembersReachedException(
|
||||
self.max_team_members,
|
||||
vendor.vendor_code,
|
||||
)
|
||||
subscription_service.check_team_limit(db, vendor.id)
|
||||
|
||||
# Check if user already exists
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
@@ -187,7 +175,7 @@ class VendorTeamService:
|
||||
"existing_user": user.is_active,
|
||||
}
|
||||
|
||||
except (TeamMemberAlreadyExistsException, MaxTeamMembersReachedException):
|
||||
except (TeamMemberAlreadyExistsException, TierLimitExceededException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error inviting team member: {str(e)}")
|
||||
|
||||
397
app/templates/vendor/billing.html
vendored
Normal file
397
app/templates/vendor/billing.html
vendored
Normal file
@@ -0,0 +1,397 @@
|
||||
{# app/templates/vendor/billing.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
|
||||
{% block title %}Billing & Subscription{% endblock %}
|
||||
|
||||
{% block alpine_data %}billingData(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Billing & Subscription
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Success/Cancel Messages -->
|
||||
<template x-if="showSuccessMessage">
|
||||
<div class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Your subscription has been updated successfully!</span>
|
||||
</div>
|
||||
<button @click="showSuccessMessage = false" class="text-green-700 hover:text-green-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="showCancelMessage">
|
||||
<div class="mb-6 p-4 bg-yellow-100 border border-yellow-400 text-yellow-700 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-5 h-5 mr-2')"></span>
|
||||
<span>Checkout was cancelled. No changes were made to your subscription.</span>
|
||||
</div>
|
||||
<button @click="showCancelMessage = false" class="text-yellow-700 hover:text-yellow-900">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loading State -->
|
||||
<template x-if="loading">
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading">
|
||||
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
<!-- Current Plan Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Current Plan</h3>
|
||||
<span :class="{
|
||||
'bg-green-100 text-green-800': subscription?.status === 'active',
|
||||
'bg-yellow-100 text-yellow-800': subscription?.status === 'trial',
|
||||
'bg-red-100 text-red-800': subscription?.status === 'past_due' || subscription?.status === 'cancelled',
|
||||
'bg-gray-100 text-gray-800': !['active', 'trial', 'past_due', 'cancelled'].includes(subscription?.status)
|
||||
}" class="px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span x-text="subscription?.status?.replace('_', ' ')?.toUpperCase() || 'INACTIVE'"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400" x-text="subscription?.tier_name || 'No Plan'"></div>
|
||||
<template x-if="subscription?.is_trial">
|
||||
<p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
Trial ends <span x-text="formatDate(subscription?.trial_ends_at)"></span>
|
||||
</p>
|
||||
</template>
|
||||
<template x-if="subscription?.cancelled_at">
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
Cancels on <span x-text="formatDate(subscription?.period_end)"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<template x-if="subscription?.period_end && !subscription?.cancelled_at">
|
||||
<p>
|
||||
Next billing: <span class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(subscription?.period_end)"></span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-2">
|
||||
<template x-if="subscription?.stripe_customer_id">
|
||||
<button @click="openPortal()"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300">
|
||||
Manage Payment Method
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="subscription?.cancelled_at">
|
||||
<button @click="reactivate()"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700">
|
||||
Reactivate Subscription
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!subscription?.cancelled_at && subscription?.status === 'active'">
|
||||
<button @click="showCancelModal = true"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-red-600 bg-red-100 rounded-lg hover:bg-red-200 dark:bg-red-900 dark:text-red-300">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Summary Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Usage This Period</h3>
|
||||
|
||||
<!-- Orders Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Orders</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.orders_this_period || 0"></span>
|
||||
<span x-text="subscription?.orders_limit ? ` / ${subscription.orders_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.orders_limit ? Math.min(100, (subscription.orders_this_period / subscription.orders_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Products</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.products_count || 0"></span>
|
||||
<span x-text="subscription?.products_limit ? ` / ${subscription.products_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.products_limit ? Math.min(100, (subscription.products_count / subscription.products_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Usage -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-gray-600 dark:text-gray-400">Team Members</span>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
<span x-text="subscription?.team_count || 0"></span>
|
||||
<span x-text="subscription?.team_limit ? ` / ${subscription.team_limit}` : ' (Unlimited)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div class="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="`width: ${subscription?.team_limit ? Math.min(100, (subscription.team_count / subscription.team_limit) * 100) : 0}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="subscription?.last_payment_error">
|
||||
<div class="mt-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">
|
||||
<span x-html="$icon('exclamation-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Payment issue: <span x-text="subscription.last_payment_error"></span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Quick Actions</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button @click="showTiersModal = true"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('arrow-trending-up', 'w-5 h-5 text-purple-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Change Plan</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
|
||||
<button @click="showAddonsModal = true"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('puzzle-piece', 'w-5 h-5 text-blue-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Add-ons</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</button>
|
||||
|
||||
<a :href="`/vendor/${vendorCode}/invoices`"
|
||||
class="w-full flex items-center justify-between px-4 py-3 text-left bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5 text-green-600 mr-3')"></span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">View Invoices</span>
|
||||
</div>
|
||||
<span x-html="$icon('chevron-right', 'w-5 h-5 text-gray-400')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice History Section -->
|
||||
<div class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Recent Invoices</h3>
|
||||
|
||||
<template x-if="invoices.length === 0">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-center py-8">No invoices yet</p>
|
||||
</template>
|
||||
|
||||
<template x-if="invoices.length > 0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Invoice</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Amount</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="invoice in invoices.slice(0, 5)" :key="invoice.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm font-medium" x-text="invoice.invoice_number || `#${invoice.id}`"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
|
||||
<td class="px-4 py-3 text-sm font-medium" x-text="formatCurrency(invoice.total_cents, invoice.currency)"></td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span :class="{
|
||||
'bg-green-100 text-green-800': invoice.status === 'paid',
|
||||
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
|
||||
'bg-red-100 text-red-800': invoice.status === 'uncollectible'
|
||||
}" class="px-2 py-1 text-xs font-semibold rounded-full" x-text="invoice.status.toUpperCase()"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="invoice.pdf_url">
|
||||
<a :href="invoice.pdf_url" target="_blank" class="text-purple-600 hover:text-purple-800">
|
||||
<span x-html="$icon('arrow-down-tray', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tiers Modal -->
|
||||
<div x-show="showTiersModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showTiersModal = false">
|
||||
<div class="w-full max-w-4xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Choose Your Plan</h3>
|
||||
<button @click="showTiersModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<template x-for="tier in tiers" :key="tier.code">
|
||||
<div :class="{'ring-2 ring-purple-600': tier.is_current}"
|
||||
class="relative p-6 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<template x-if="tier.is_current">
|
||||
<span class="absolute top-0 right-0 px-2 py-1 text-xs font-semibold text-white bg-purple-600 rounded-bl-lg rounded-tr-lg">Current</span>
|
||||
</template>
|
||||
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="tier.name"></h4>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<span x-text="formatCurrency(tier.price_monthly_cents, 'EUR')"></span>
|
||||
<span class="text-sm font-normal text-gray-500">/mo</span>
|
||||
</p>
|
||||
<ul class="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.orders_per_month ? `${tier.orders_per_month} orders/mo` : 'Unlimited orders'"></span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.products_limit ? `${tier.products_limit} products` : 'Unlimited products'"></span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-4 h-4 text-green-500 mr-2')"></span>
|
||||
<span x-text="tier.team_members ? `${tier.team_members} team members` : 'Unlimited team'"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<button @click="selectTier(tier)"
|
||||
:disabled="tier.is_current"
|
||||
:class="tier.is_current ? 'bg-gray-300 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700'"
|
||||
class="w-full mt-4 px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors">
|
||||
<span x-text="tier.is_current ? 'Current Plan' : (tier.can_upgrade ? 'Upgrade' : 'Downgrade')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add-ons Modal -->
|
||||
<div x-show="showAddonsModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showAddonsModal = false">
|
||||
<div class="w-full max-w-2xl mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Add-ons</h3>
|
||||
<button @click="showAddonsModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<template x-if="addons.length === 0">
|
||||
<p class="text-gray-500 text-center py-8">No add-ons available</p>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<template x-for="addon in addons" :key="addon.id">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-200" x-text="addon.name"></h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="addon.description"></p>
|
||||
<p class="text-sm font-medium text-purple-600 mt-1">
|
||||
<span x-text="formatCurrency(addon.price_cents, 'EUR')"></span>
|
||||
<span x-text="`/${addon.billing_period}`"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Subscription Modal -->
|
||||
<div x-show="showCancelModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center overflow-auto bg-black bg-opacity-50"
|
||||
@click.self="showCancelModal = false">
|
||||
<div class="w-full max-w-md mx-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Cancel Subscription</h3>
|
||||
<button @click="showCancelModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x-mark', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Are you sure you want to cancel your subscription? You'll continue to have access until the end of your current billing period.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Reason for cancelling (optional)
|
||||
</label>
|
||||
<textarea x-model="cancelReason"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Tell us why you're leaving..."></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="showCancelModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300">
|
||||
Keep Subscription
|
||||
</button>
|
||||
<button @click="cancelSubscription()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700">
|
||||
Cancel Subscription
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/vendor/js/billing.js"></script>
|
||||
{% endblock %}
|
||||
11
app/templates/vendor/partials/sidebar.html
vendored
11
app/templates/vendor/partials/sidebar.html
vendored
@@ -184,6 +184,17 @@ Follows same pattern as admin sidebar
|
||||
<span class="ml-4">Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="relative px-6 py-3">
|
||||
<span x-show="currentPage === 'billing'"
|
||||
class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg"
|
||||
aria-hidden="true"></span>
|
||||
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
:class="currentPage === 'billing' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||
:href="`/vendor/${vendorCode}/billing`">
|
||||
<span x-html="$icon('credit-card', 'w-5 h-5')"></span>
|
||||
<span class="ml-4">Billing</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
|
||||
Reference in New Issue
Block a user