diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index bca1ba411..ba385264c 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -33,6 +33,8 @@ REDIS_APP_URL=redis://localhost:6379/0 # # Run every 2 hours # SCHEDULE_CHECKER_INTERVAL=2h SCHEDULE_CHECKER_INTERVAL=5m +# How often the Stripe reconciliation beat task runs +STRIPE_RECONCILIATION_INTERVAL=10m SECRET_KEY=SECRET @@ -42,6 +44,20 @@ SECRET_KEY=SECRET NEXT_FRONTEND_URL=http://localhost:3000 +# Stripe Checkout for pay-as-you-go page packs +# Configure STRIPE_PRICE_ID to point at your 1,000-page price in Stripe. +# Pages granted per purchase = quantity * STRIPE_PAGES_PER_UNIT. +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_ID=price_... +STRIPE_PAGES_PER_UNIT=1000 +# Set FALSE to disable new checkout session creation temporarily +STRIPE_PAGE_BUYING_ENABLED=TRUE +# Periodic Stripe safety net for purchases left in PENDING (minutes old) +STRIPE_RECONCILIATION_LOOKBACK_MINUTES=10 +# Max pending purchases to check per reconciliation run +STRIPE_RECONCILIATION_BATCH_SIZE=100 + # Backend URL for OAuth callbacks (optional, set when behind reverse proxy with HTTPS) # BACKEND_URL=https://api.yourdomain.com @@ -149,6 +165,14 @@ STT_SERVICE=local/base # STT_SERVICE_API_KEY="" # STT_SERVICE_API_BASE= +# Video presentation defaults +# Maximum number of generated slides for a single video presentation. +VIDEO_PRESENTATION_MAX_SLIDES=30 +# Frames per second used for slide timing calculations. +VIDEO_PRESENTATION_FPS=30 +# Minimum duration per slide when audio is missing or very short. +VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES=300 + # (Optional) Maximum pages limit per user for ETL services (default: `999999999` for unlimited in OSS version) PAGES_LIMIT=500 diff --git a/surfsense_backend/alembic/versions/115_add_page_purchases_table.py b/surfsense_backend/alembic/versions/115_add_page_purchases_table.py new file mode 100644 index 000000000..fc2bb973b --- /dev/null +++ b/surfsense_backend/alembic/versions/115_add_page_purchases_table.py @@ -0,0 +1,120 @@ +"""add page purchases table for Stripe-backed page packs + +Revision ID: 115 +Revises: 114 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "115" +down_revision: str | None = "114" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create page_purchases table and supporting enum/indexes.""" + conn = op.get_bind() + + enum_exists = conn.execute( + sa.text("SELECT 1 FROM pg_type WHERE typname = 'pagepurchasestatus'") + ).fetchone() + if not enum_exists: + page_purchase_status_enum = postgresql.ENUM( + "PENDING", + "COMPLETED", + "FAILED", + name="pagepurchasestatus", + create_type=False, + ) + page_purchase_status_enum.create(conn, checkfirst=True) + + table_exists = conn.execute( + sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'page_purchases'" + ) + ).fetchone() + if not table_exists: + op.create_table( + "page_purchases", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "stripe_checkout_session_id", + sa.String(length=255), + nullable=False, + ), + sa.Column( + "stripe_payment_intent_id", + sa.String(length=255), + nullable=True, + ), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("pages_granted", sa.Integer(), nullable=False), + sa.Column("amount_total", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(length=10), nullable=True), + sa.Column( + "status", + postgresql.ENUM( + "PENDING", + "COMPLETED", + "FAILED", + name="pagepurchasestatus", + create_type=False, + ), + nullable=False, + server_default=sa.text("'PENDING'::pagepurchasestatus"), + ), + sa.Column("completed_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "stripe_checkout_session_id", + name="uq_page_purchases_stripe_checkout_session_id", + ), + ) + + op.execute( + "CREATE INDEX IF NOT EXISTS ix_page_purchases_user_id ON page_purchases (user_id)" + ) + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_page_purchases_stripe_checkout_session_id " + "ON page_purchases (stripe_checkout_session_id)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_page_purchases_stripe_payment_intent_id " + "ON page_purchases (stripe_payment_intent_id)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_page_purchases_status ON page_purchases (status)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_page_purchases_created_at " + "ON page_purchases (created_at)" + ) + + +def downgrade() -> None: + """Drop page_purchases table and enum.""" + op.execute("DROP INDEX IF EXISTS ix_page_purchases_created_at") + op.execute("DROP INDEX IF EXISTS ix_page_purchases_status") + op.execute("DROP INDEX IF EXISTS ix_page_purchases_stripe_payment_intent_id") + op.execute("DROP INDEX IF EXISTS ix_page_purchases_stripe_checkout_session_id") + op.execute("DROP INDEX IF EXISTS ix_page_purchases_user_id") + op.execute("DROP TABLE IF EXISTS page_purchases") + postgresql.ENUM(name="pagepurchasestatus").drop(op.get_bind(), checkfirst=True) diff --git a/surfsense_backend/app/agents/video_presentation/prompts.py b/surfsense_backend/app/agents/video_presentation/prompts.py index 5533bb01c..4bc281cdb 100644 --- a/surfsense_backend/app/agents/video_presentation/prompts.py +++ b/surfsense_backend/app/agents/video_presentation/prompts.py @@ -1,9 +1,10 @@ import datetime -# TODO: move these to config file -MAX_SLIDES = 5 -FPS = 30 -DEFAULT_DURATION_IN_FRAMES = 300 +from app.config import config as app_config + +MAX_SLIDES = app_config.VIDEO_PRESENTATION_MAX_SLIDES +FPS = app_config.VIDEO_PRESENTATION_FPS +DEFAULT_DURATION_IN_FRAMES = app_config.VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES THEME_PRESETS = [ "TERRA", diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py index 69e117747..684da6a13 100644 --- a/surfsense_backend/app/celery_app.py +++ b/surfsense_backend/app/celery_app.py @@ -33,6 +33,7 @@ CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "surfsense") # Format: "" where unit is 'm' (minutes) or 'h' (hours) # Examples: "1m" (every minute), "5m" (every 5 minutes), "1h" (every hour) SCHEDULE_CHECKER_INTERVAL = os.getenv("SCHEDULE_CHECKER_INTERVAL", "2m") +STRIPE_RECONCILIATION_INTERVAL = os.getenv("STRIPE_RECONCILIATION_INTERVAL", "10m") def parse_schedule_interval(interval: str) -> dict: @@ -68,6 +69,9 @@ def parse_schedule_interval(interval: str) -> dict: # Parse the schedule interval schedule_params = parse_schedule_interval(SCHEDULE_CHECKER_INTERVAL) +stripe_reconciliation_schedule_params = parse_schedule_interval( + STRIPE_RECONCILIATION_INTERVAL +) # Create Celery app celery_app = Celery( @@ -82,6 +86,7 @@ celery_app = Celery( "app.tasks.celery_tasks.schedule_checker_task", "app.tasks.celery_tasks.document_reindex_tasks", "app.tasks.celery_tasks.stale_notification_cleanup_task", + "app.tasks.celery_tasks.stripe_reconciliation_task", ], ) @@ -169,4 +174,12 @@ celery_app.conf.beat_schedule = { "expires": 60, # Task expires after 60 seconds if not picked up }, }, + # Reconcile Stripe purchases that were paid but remained pending + "reconcile-pending-stripe-page-purchases": { + "task": "reconcile_pending_stripe_page_purchases", + "schedule": crontab(**stripe_reconciliation_schedule_params), + "options": { + "expires": 60, + }, + }, } diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 1fabfc7b6..7c4baf923 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -231,6 +231,21 @@ class Config: # Backend URL to override the http to https in the OAuth redirect URI BACKEND_URL = os.getenv("BACKEND_URL") + # Stripe checkout for pay-as-you-go page packs + STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY") + STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") + STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID") + STRIPE_PAGES_PER_UNIT = int(os.getenv("STRIPE_PAGES_PER_UNIT", "1000")) + STRIPE_PAGE_BUYING_ENABLED = ( + os.getenv("STRIPE_PAGE_BUYING_ENABLED", "TRUE").upper() == "TRUE" + ) + STRIPE_RECONCILIATION_LOOKBACK_MINUTES = int( + os.getenv("STRIPE_RECONCILIATION_LOOKBACK_MINUTES", "10") + ) + STRIPE_RECONCILIATION_BATCH_SIZE = int( + os.getenv("STRIPE_RECONCILIATION_BATCH_SIZE", "100") + ) + # Auth AUTH_TYPE = os.getenv("AUTH_TYPE") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" @@ -400,6 +415,15 @@ class Config: STT_SERVICE_API_BASE = os.getenv("STT_SERVICE_API_BASE") STT_SERVICE_API_KEY = os.getenv("STT_SERVICE_API_KEY") + # Video presentation defaults + VIDEO_PRESENTATION_MAX_SLIDES = int( + os.getenv("VIDEO_PRESENTATION_MAX_SLIDES", "30") + ) + VIDEO_PRESENTATION_FPS = int(os.getenv("VIDEO_PRESENTATION_FPS", "30")) + VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES = int( + os.getenv("VIDEO_PRESENTATION_DEFAULT_DURATION_IN_FRAMES", "300") + ) + # Validation Checks # Check embedding dimension if ( diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index ffa12aac6..90630cc83 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1,3 +1,4 @@ +import uuid from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -292,6 +293,12 @@ class IncentiveTaskType(StrEnum): # REFER_FRIEND = "REFER_FRIEND" +class PagePurchaseStatus(StrEnum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + + # Centralized configuration for incentive tasks # This makes it easy to add new tasks without changing code in multiple places INCENTIVE_TASKS_CONFIG = { @@ -1643,6 +1650,39 @@ class UserIncentiveTask(BaseModel, TimestampMixin): user = relationship("User", back_populates="incentive_tasks") +class PagePurchase(Base, TimestampMixin): + """Tracks Stripe checkout sessions used to grant additional page credits.""" + + __tablename__ = "page_purchases" + __allow_unmapped__ = True + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + stripe_checkout_session_id = Column( + String(255), nullable=False, unique=True, index=True + ) + stripe_payment_intent_id = Column(String(255), nullable=True, index=True) + quantity = Column(Integer, nullable=False) + pages_granted = Column(Integer, nullable=False) + amount_total = Column(Integer, nullable=True) + currency = Column(String(10), nullable=True) + status = Column( + SQLAlchemyEnum(PagePurchaseStatus), + nullable=False, + default=PagePurchaseStatus.PENDING, + server_default=text("'PENDING'::pagepurchasestatus"), + index=True, + ) + completed_at = Column(TIMESTAMP(timezone=True), nullable=True) + + user = relationship("User", back_populates="page_purchases") + + class SearchSpaceRole(BaseModel, TimestampMixin): """ Custom roles that can be defined per search space. @@ -1910,6 +1950,11 @@ if config.AUTH_TYPE == "GOOGLE": back_populates="user", cascade="all, delete-orphan", ) + page_purchases = relationship( + "PagePurchase", + back_populates="user", + cascade="all, delete-orphan", + ) # Page usage tracking for ETL services pages_limit = Column( @@ -2019,6 +2064,11 @@ else: back_populates="user", cascade="all, delete-orphan", ) + page_purchases = relationship( + "PagePurchase", + back_populates="user", + cascade="all, delete-orphan", + ) # Page usage tracking for ETL services pages_limit = Column( diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 983af7597..1937f11cb 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -44,6 +44,7 @@ from .sandbox_routes import router as sandbox_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router +from .stripe_routes import router as stripe_router from .surfsense_docs_routes import router as surfsense_docs_router from .teams_add_connector_route import router as teams_add_connector_router from .video_presentations_routes import router as video_presentations_router @@ -91,5 +92,6 @@ router.include_router(notifications_router) # Notifications with Zero sync router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages +router.include_router(stripe_router) # Stripe checkout for additional page packs router.include_router(youtube_router) # YouTube playlist resolution router.include_router(prompts_router) diff --git a/surfsense_backend/app/routes/incentive_tasks_routes.py b/surfsense_backend/app/routes/incentive_tasks_routes.py index 93e54c153..496b07d06 100644 --- a/surfsense_backend/app/routes/incentive_tasks_routes.py +++ b/surfsense_backend/app/routes/incentive_tasks_routes.py @@ -117,8 +117,10 @@ async def complete_task( ) session.add(new_task) - # Update user's pages_limit - user.pages_limit += pages_reward + # pages_used can exceed pages_limit when a document's final page count is + # determined after processing. Base the new limit on the higher of the two + # so the rewarded pages are fully usable above the current high-water mark. + user.pages_limit = max(user.pages_used, user.pages_limit) + pages_reward await session.commit() await session.refresh(user) diff --git a/surfsense_backend/app/routes/stripe_routes.py b/surfsense_backend/app/routes/stripe_routes.py new file mode 100644 index 000000000..672f67cad --- /dev/null +++ b/surfsense_backend/app/routes/stripe_routes.py @@ -0,0 +1,371 @@ +"""Stripe routes for pay-as-you-go page purchases.""" + +from __future__ import annotations + +import logging +import uuid +from datetime import UTC, datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from stripe import SignatureVerificationError, StripeClient, StripeError + +from app.config import config +from app.db import PagePurchase, PagePurchaseStatus, User, get_async_session +from app.schemas.stripe import ( + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + PagePurchaseHistoryResponse, + StripeStatusResponse, + StripeWebhookResponse, +) +from app.users import current_active_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/stripe", tags=["stripe"]) + + +def get_stripe_client() -> StripeClient: + """Return a configured Stripe client or raise if Stripe is disabled.""" + if not config.STRIPE_SECRET_KEY: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Stripe checkout is not configured.", + ) + return StripeClient(config.STRIPE_SECRET_KEY) + + +def _ensure_page_buying_enabled() -> None: + if not config.STRIPE_PAGE_BUYING_ENABLED: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Page purchases are temporarily unavailable.", + ) + + +def _get_checkout_urls(search_space_id: int) -> tuple[str, str]: + if not config.NEXT_FRONTEND_URL: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="NEXT_FRONTEND_URL is not configured.", + ) + + base_url = config.NEXT_FRONTEND_URL.rstrip("/") + success_url = f"{base_url}/dashboard/{search_space_id}/purchase-success" + cancel_url = f"{base_url}/dashboard/{search_space_id}/purchase-cancel" + return success_url, cancel_url + + +def _get_required_stripe_price_id() -> str: + if not config.STRIPE_PRICE_ID: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="STRIPE_PRICE_ID is not configured.", + ) + return config.STRIPE_PRICE_ID + + +def _normalize_optional_string(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return getattr(value, "id", str(value)) + + +def _get_metadata(checkout_session: Any) -> dict[str, str]: + metadata = getattr(checkout_session, "metadata", None) or {} + if isinstance(metadata, dict): + return {str(key): str(value) for key, value in metadata.items()} + return dict(metadata) + + +async def _get_or_create_purchase_from_checkout_session( + db_session: AsyncSession, + checkout_session: Any, +) -> PagePurchase | None: + """Look up a PagePurchase by checkout session ID (with FOR UPDATE lock). + + If the row doesn't exist yet (e.g. the webhook arrived before the API + response committed), create one from the Stripe session metadata. + """ + checkout_session_id = str(checkout_session.id) + purchase = ( + await db_session.execute( + select(PagePurchase) + .where(PagePurchase.stripe_checkout_session_id == checkout_session_id) + .with_for_update() + ) + ).scalar_one_or_none() + if purchase is not None: + return purchase + + metadata = _get_metadata(checkout_session) + user_id = metadata.get("user_id") + quantity = int(metadata.get("quantity", "0")) + pages_per_unit = int(metadata.get("pages_per_unit", "0")) + + if not user_id or quantity <= 0 or pages_per_unit <= 0: + logger.error( + "Skipping Stripe fulfillment for session %s due to incomplete metadata: %s", + checkout_session_id, + metadata, + ) + return None + + purchase = PagePurchase( + user_id=uuid.UUID(user_id), + stripe_checkout_session_id=checkout_session_id, + stripe_payment_intent_id=_normalize_optional_string( + getattr(checkout_session, "payment_intent", None) + ), + quantity=quantity, + pages_granted=quantity * pages_per_unit, + amount_total=getattr(checkout_session, "amount_total", None), + currency=getattr(checkout_session, "currency", None), + status=PagePurchaseStatus.PENDING, + ) + db_session.add(purchase) + await db_session.flush() + return purchase + + +async def _mark_purchase_failed( + db_session: AsyncSession, checkout_session_id: str +) -> StripeWebhookResponse: + purchase = ( + await db_session.execute( + select(PagePurchase) + .where(PagePurchase.stripe_checkout_session_id == checkout_session_id) + .with_for_update() + ) + ).scalar_one_or_none() + + if purchase is not None and purchase.status == PagePurchaseStatus.PENDING: + purchase.status = PagePurchaseStatus.FAILED + await db_session.commit() + + return StripeWebhookResponse() + + +async def _fulfill_completed_purchase( + db_session: AsyncSession, checkout_session: Any +) -> StripeWebhookResponse: + """Grant pages to the user after a confirmed Stripe payment. + + Uses SELECT ... FOR UPDATE on both the PagePurchase and User rows to + prevent double-granting when Stripe retries the webhook concurrently. + """ + purchase = await _get_or_create_purchase_from_checkout_session( + db_session, checkout_session + ) + if purchase is None: + return StripeWebhookResponse() + + if purchase.status == PagePurchaseStatus.COMPLETED: + return StripeWebhookResponse() + + user = ( + ( + await db_session.execute( + select(User).where(User.id == purchase.user_id).with_for_update(of=User) + ) + ) + .unique() + .scalar_one_or_none() + ) + if user is None: + logger.error( + "Skipping Stripe fulfillment for session %s because user %s was not found.", + purchase.stripe_checkout_session_id, + purchase.user_id, + ) + return StripeWebhookResponse() + + purchase.status = PagePurchaseStatus.COMPLETED + purchase.completed_at = datetime.now(UTC) + purchase.amount_total = getattr(checkout_session, "amount_total", None) + purchase.currency = getattr(checkout_session, "currency", None) + purchase.stripe_payment_intent_id = _normalize_optional_string( + getattr(checkout_session, "payment_intent", None) + ) + # pages_used can exceed pages_limit when a document's final page count is + # determined after processing. Base the new limit on the higher of the two + # so the purchased pages are fully usable above the current high-water mark. + user.pages_limit = max(user.pages_used, user.pages_limit) + purchase.pages_granted + + await db_session.commit() + return StripeWebhookResponse() + + +@router.post("/create-checkout-session", response_model=CreateCheckoutSessionResponse) +async def create_checkout_session( + body: CreateCheckoutSessionRequest, + user: User = Depends(current_active_user), + db_session: AsyncSession = Depends(get_async_session), +) -> CreateCheckoutSessionResponse: + """Create a Stripe Checkout Session for buying page packs.""" + _ensure_page_buying_enabled() + stripe_client = get_stripe_client() + price_id = _get_required_stripe_price_id() + success_url, cancel_url = _get_checkout_urls(body.search_space_id) + pages_granted = body.quantity * config.STRIPE_PAGES_PER_UNIT + + try: + checkout_session = stripe_client.v1.checkout.sessions.create( + params={ + "mode": "payment", + "success_url": success_url, + "cancel_url": cancel_url, + "line_items": [ + { + "price": price_id, + "quantity": body.quantity, + } + ], + "client_reference_id": str(user.id), + "customer_email": user.email, + "metadata": { + "user_id": str(user.id), + "quantity": str(body.quantity), + "pages_per_unit": str(config.STRIPE_PAGES_PER_UNIT), + "purchase_type": "page_packs", + }, + } + ) + except StripeError as exc: + logger.exception( + "Failed to create Stripe checkout session for user %s", user.id + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Unable to create Stripe checkout session.", + ) from exc + + checkout_url = getattr(checkout_session, "url", None) + if not checkout_url: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Stripe checkout session did not return a URL.", + ) + + db_session.add( + PagePurchase( + user_id=user.id, + stripe_checkout_session_id=str(checkout_session.id), + stripe_payment_intent_id=_normalize_optional_string( + getattr(checkout_session, "payment_intent", None) + ), + quantity=body.quantity, + pages_granted=pages_granted, + amount_total=getattr(checkout_session, "amount_total", None), + currency=getattr(checkout_session, "currency", None), + status=PagePurchaseStatus.PENDING, + ) + ) + await db_session.commit() + + return CreateCheckoutSessionResponse(checkout_url=checkout_url) + + +@router.get("/status", response_model=StripeStatusResponse) +async def get_stripe_status() -> StripeStatusResponse: + """Return page-buying availability for frontend feature gating.""" + return StripeStatusResponse(page_buying_enabled=config.STRIPE_PAGE_BUYING_ENABLED) + + +@router.post("/webhook", response_model=StripeWebhookResponse) +async def stripe_webhook( + request: Request, + db_session: AsyncSession = Depends(get_async_session), +) -> StripeWebhookResponse: + """Handle Stripe webhooks and grant purchased pages after payment.""" + if not config.STRIPE_WEBHOOK_SECRET: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Stripe webhook handling is not configured.", + ) + + stripe_client = get_stripe_client() + payload = await request.body() + signature = request.headers.get("Stripe-Signature") + + if not signature: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing Stripe-Signature header.", + ) + + try: + event = stripe_client.construct_event( + payload, + signature, + config.STRIPE_WEBHOOK_SECRET, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Stripe webhook payload.", + ) from exc + except SignatureVerificationError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Stripe webhook signature.", + ) from exc + + if event.type in { + "checkout.session.completed", + "checkout.session.async_payment_succeeded", + }: + checkout_session = event.data.object + payment_status = getattr(checkout_session, "payment_status", None) + + if event.type == "checkout.session.completed" and payment_status not in { + "paid", + "no_payment_required", + }: + logger.info( + "Received checkout.session.completed for unpaid session %s; waiting for async success.", + checkout_session.id, + ) + return StripeWebhookResponse() + + return await _fulfill_completed_purchase(db_session, checkout_session) + + if event.type in { + "checkout.session.async_payment_failed", + "checkout.session.expired", + }: + checkout_session = event.data.object + return await _mark_purchase_failed(db_session, str(checkout_session.id)) + + return StripeWebhookResponse() + + +@router.get("/purchases", response_model=PagePurchaseHistoryResponse) +async def get_page_purchases( + user: User = Depends(current_active_user), + db_session: AsyncSession = Depends(get_async_session), + offset: int = 0, + limit: int = 50, +) -> PagePurchaseHistoryResponse: + """Return the authenticated user's page-purchase history.""" + limit = min(limit, 100) + purchases = ( + ( + await db_session.execute( + select(PagePurchase) + .where(PagePurchase.user_id == user.id) + .order_by(PagePurchase.created_at.desc()) + .offset(offset) + .limit(limit) + ) + ) + .scalars() + .all() + ) + + return PagePurchaseHistoryResponse(purchases=purchases) diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index ef926131d..b94a30c19 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -110,6 +110,14 @@ from .search_space import ( SearchSpaceUpdate, SearchSpaceWithStats, ) +from .stripe import ( + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + PagePurchaseHistoryResponse, + PagePurchaseRead, + StripeStatusResponse, + StripeWebhookResponse, +) from .users import UserCreate, UserRead, UserUpdate from .video_presentations import ( VideoPresentationBase, @@ -128,6 +136,8 @@ __all__ = [ "ChunkCreate", "ChunkRead", "ChunkUpdate", + "CreateCheckoutSessionRequest", + "CreateCheckoutSessionResponse", "DefaultSystemInstructionsResponse", # Document schemas "DocumentBase", @@ -207,6 +217,8 @@ __all__ = [ "NewLLMConfigPublic", "NewLLMConfigRead", "NewLLMConfigUpdate", + "PagePurchaseHistoryResponse", + "PagePurchaseRead", "PaginatedResponse", "PermissionInfo", "PermissionsListResponse", @@ -236,6 +248,8 @@ __all__ = [ "SearchSpaceRead", "SearchSpaceUpdate", "SearchSpaceWithStats", + "StripeStatusResponse", + "StripeWebhookResponse", "ThreadHistoryLoadResponse", "ThreadListItem", "ThreadListResponse", diff --git a/surfsense_backend/app/schemas/stripe.py b/surfsense_backend/app/schemas/stripe.py new file mode 100644 index 000000000..1c3185601 --- /dev/null +++ b/surfsense_backend/app/schemas/stripe.py @@ -0,0 +1,56 @@ +"""Schemas for Stripe-backed page purchases.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.db import PagePurchaseStatus + + +class CreateCheckoutSessionRequest(BaseModel): + """Request body for creating a page-purchase checkout session.""" + + quantity: int = Field(ge=1, le=100) + search_space_id: int = Field(ge=1) + + +class CreateCheckoutSessionResponse(BaseModel): + """Response containing the Stripe-hosted checkout URL.""" + + checkout_url: str + + +class StripeStatusResponse(BaseModel): + """Response describing Stripe page-buying availability.""" + + page_buying_enabled: bool + + +class PagePurchaseRead(BaseModel): + """Serialized page-purchase record for purchase history.""" + + id: uuid.UUID + stripe_checkout_session_id: str + stripe_payment_intent_id: str | None = None + quantity: int + pages_granted: int + amount_total: int | None = None + currency: str | None = None + status: PagePurchaseStatus + completed_at: datetime | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class PagePurchaseHistoryResponse(BaseModel): + """Response containing the authenticated user's page purchases.""" + + purchases: list[PagePurchaseRead] + + +class StripeWebhookResponse(BaseModel): + """Generic acknowledgement for Stripe webhook delivery.""" + + received: bool = True diff --git a/surfsense_backend/app/tasks/celery_tasks/stripe_reconciliation_task.py b/surfsense_backend/app/tasks/celery_tasks/stripe_reconciliation_task.py new file mode 100644 index 000000000..9b64ee0ca --- /dev/null +++ b/surfsense_backend/app/tasks/celery_tasks/stripe_reconciliation_task.py @@ -0,0 +1,133 @@ +"""Reconcile pending Stripe page purchases that might miss webhook fulfillment.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select +from stripe import StripeClient, StripeError + +from app.celery_app import celery_app +from app.config import config +from app.db import PagePurchase, PagePurchaseStatus +from app.routes import stripe_routes +from app.tasks.celery_tasks import get_celery_session_maker + +logger = logging.getLogger(__name__) + + +def get_stripe_client() -> StripeClient | None: + """Return a Stripe client for reconciliation, or None when disabled.""" + if not config.STRIPE_SECRET_KEY: + logger.warning( + "Stripe reconciliation skipped because STRIPE_SECRET_KEY is not configured." + ) + return None + return StripeClient(config.STRIPE_SECRET_KEY) + + +@celery_app.task(name="reconcile_pending_stripe_page_purchases") +def reconcile_pending_stripe_page_purchases_task(): + """Recover paid purchases that were left pending due to missed webhook handling.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_until_complete(_reconcile_pending_page_purchases()) + finally: + loop.close() + + +async def _reconcile_pending_page_purchases() -> None: + """Reconcile stale pending page purchases against Stripe source of truth. + + Stripe retries webhook delivery automatically, but best practice is to add an + application-level reconciliation path in case all retries fail or the endpoint + is unavailable for an extended window. + """ + stripe_client = get_stripe_client() + if stripe_client is None: + return + + lookback_minutes = max(config.STRIPE_RECONCILIATION_LOOKBACK_MINUTES, 0) + batch_size = max(config.STRIPE_RECONCILIATION_BATCH_SIZE, 1) + cutoff = datetime.now(UTC) - timedelta(minutes=lookback_minutes) + + async with get_celery_session_maker()() as db_session: + pending_purchases = ( + ( + await db_session.execute( + select(PagePurchase) + .where( + PagePurchase.status == PagePurchaseStatus.PENDING, + PagePurchase.created_at <= cutoff, + ) + .order_by(PagePurchase.created_at.asc()) + .limit(batch_size) + ) + ) + .scalars() + .all() + ) + + if not pending_purchases: + logger.debug( + "Stripe reconciliation found no pending purchases older than %s minutes.", + lookback_minutes, + ) + return + + logger.info( + "Stripe reconciliation checking %s pending purchases (cutoff=%s, batch=%s).", + len(pending_purchases), + lookback_minutes, + batch_size, + ) + + fulfilled_count = 0 + failed_count = 0 + + for purchase in pending_purchases: + checkout_session_id = purchase.stripe_checkout_session_id + + try: + checkout_session = stripe_client.v1.checkout.sessions.retrieve( + checkout_session_id + ) + except StripeError: + logger.exception( + "Stripe reconciliation failed to retrieve checkout session %s", + checkout_session_id, + ) + await db_session.rollback() + continue + + payment_status = getattr(checkout_session, "payment_status", None) + session_status = getattr(checkout_session, "status", None) + + try: + if payment_status in {"paid", "no_payment_required"}: + await stripe_routes._fulfill_completed_purchase( + db_session, checkout_session + ) + fulfilled_count += 1 + elif session_status == "expired": + await stripe_routes._mark_purchase_failed( + db_session, str(checkout_session.id) + ) + failed_count += 1 + except Exception: + logger.exception( + "Stripe reconciliation failed while processing checkout session %s", + checkout_session_id, + ) + await db_session.rollback() + + logger.info( + "Stripe reconciliation completed. fulfilled=%s failed=%s checked=%s", + fulfilled_count, + failed_count, + len(pending_purchases), + ) diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 4f9fa8a6e..260765908 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -74,6 +74,7 @@ dependencies = [ "langgraph>=1.1.3", "langchain-community>=0.4.1", "deepagents>=0.4.12", + "stripe>=15.0.0", ] [dependency-groups] diff --git a/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py new file mode 100644 index 000000000..af1e8c728 --- /dev/null +++ b/surfsense_backend/tests/integration/document_upload/test_stripe_page_purchases.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +import asyncpg +import httpx +import pytest +import pytest_asyncio +from httpx import ASGITransport + +from app.app import app +from app.routes import stripe_routes +from app.tasks.celery_tasks import stripe_reconciliation_task +from tests.conftest import TEST_DATABASE_URL +from tests.utils.helpers import TEST_EMAIL, TEST_PASSWORD, auth_headers + +pytestmark = pytest.mark.integration + +_ASYNCPG_URL = TEST_DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") + + +async def _execute(query: str, *args) -> None: + conn = await asyncpg.connect(_ASYNCPG_URL) + try: + await conn.execute(query, *args) + finally: + await conn.close() + + +async def _fetchrow(query: str, *args): + conn = await asyncpg.connect(_ASYNCPG_URL) + try: + return await conn.fetchrow(query, *args) + finally: + await conn.close() + + +async def _get_user_id(email: str) -> str: + row = await _fetchrow('SELECT id FROM "user" WHERE email = $1', email) + assert row is not None, f"User {email!r} not found" + return str(row["id"]) + + +async def _get_pages_limit(email: str) -> int: + row = await _fetchrow('SELECT pages_limit FROM "user" WHERE email = $1', email) + assert row is not None, f"User {email!r} not found" + return row["pages_limit"] + + +def _extract_access_token(response: httpx.Response) -> str | None: + if response.status_code == 200: + return response.json()["access_token"] + + if response.status_code == 302: + location = response.headers.get("location", "") + return parse_qs(urlparse(location).query).get("token", [None])[0] + + return None + + +async def _authenticate_test_user(client: httpx.AsyncClient) -> str: + response = await client.post( + "/auth/jwt/login", + data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = _extract_access_token(response) + if token: + return token + + reg_response = await client.post( + "/auth/register", + json={"email": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + assert reg_response.status_code == 201, ( + f"Registration failed ({reg_response.status_code}): {reg_response.text}" + ) + + response = await client.post( + "/auth/jwt/login", + data={"username": TEST_EMAIL, "password": TEST_PASSWORD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = _extract_access_token(response) + assert token, f"Login failed ({response.status_code}): {response.text}" + return token + + +@pytest_asyncio.fixture(scope="session") +async def auth_token(_ensure_tables) -> str: + async with httpx.AsyncClient( + transport=ASGITransport(app=app), base_url="http://test", timeout=30.0 + ) as client: + return await _authenticate_test_user(client) + + +@pytest.fixture(scope="session") +def headers(auth_token: str) -> dict[str, str]: + return auth_headers(auth_token) + + +@pytest.fixture(autouse=True) +async def _cleanup_page_purchases(): + await _execute("DELETE FROM page_purchases") + yield + await _execute("DELETE FROM page_purchases") + + +class _FakeCreateStripeClient: + def __init__(self, checkout_session): + self.checkout_session = checkout_session + self.last_params = None + self.v1 = SimpleNamespace( + checkout=SimpleNamespace( + sessions=SimpleNamespace(create=self._create_session) + ) + ) + + def _create_session(self, *, params): + self.last_params = params + return self.checkout_session + + +class _FakeWebhookStripeClient: + def __init__(self, event): + self.event = event + self.last_payload = None + self.last_signature = None + self.last_secret = None + + def construct_event(self, payload, signature, secret): + self.last_payload = payload + self.last_signature = signature + self.last_secret = secret + return self.event + + +class _FakeReconciliationStripeClient: + def __init__(self, checkout_session): + self.checkout_session = checkout_session + self.requested_ids = [] + self.v1 = SimpleNamespace( + checkout=SimpleNamespace( + sessions=SimpleNamespace(retrieve=self._retrieve_session) + ) + ) + + def _retrieve_session(self, checkout_session_id: str): + self.requested_ids.append(checkout_session_id) + return self.checkout_session + + +class TestStripeCheckoutSessionCreation: + async def test_get_status_reflects_backend_toggle(self, client, headers, monkeypatch): + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False) + disabled_response = await client.get("/api/v1/stripe/status", headers=headers) + assert disabled_response.status_code == 200, disabled_response.text + assert disabled_response.json() == {"page_buying_enabled": False} + + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", True) + enabled_response = await client.get("/api/v1/stripe/status", headers=headers) + assert enabled_response.status_code == 200, enabled_response.text + assert enabled_response.json() == {"page_buying_enabled": True} + + async def test_create_checkout_session_records_pending_purchase( + self, + client, + headers, + search_space_id: int, + monkeypatch, + ): + checkout_session = SimpleNamespace( + id="cs_test_create_123", + url="https://checkout.stripe.test/cs_test_create_123", + payment_intent=None, + amount_total=None, + currency=None, + ) + fake_client = _FakeCreateStripeClient(checkout_session) + + monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: fake_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") + monkeypatch.setattr( + stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" + ) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000) + + response = await client.post( + "/api/v1/stripe/create-checkout-session", + headers=headers, + json={"quantity": 2, "search_space_id": search_space_id}, + ) + + assert response.status_code == 200, response.text + assert response.json() == {"checkout_url": checkout_session.url} + assert fake_client.last_params is not None + assert fake_client.last_params["mode"] == "payment" + assert fake_client.last_params["line_items"] == [ + {"price": "price_pages_1000", "quantity": 2} + ] + assert ( + fake_client.last_params["success_url"] + == f"http://localhost:3000/dashboard/{search_space_id}/purchase-success" + ) + assert ( + fake_client.last_params["cancel_url"] + == f"http://localhost:3000/dashboard/{search_space_id}/purchase-cancel" + ) + + purchase = await _fetchrow( + """ + SELECT quantity, pages_granted, status + FROM page_purchases + WHERE stripe_checkout_session_id = $1 + """, + checkout_session.id, + ) + assert purchase is not None + assert purchase["quantity"] == 2 + assert purchase["pages_granted"] == 2000 + assert purchase["status"] == "PENDING" + + async def test_create_checkout_session_returns_503_when_buying_disabled( + self, + client, + headers, + search_space_id: int, + monkeypatch, + ): + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGE_BUYING_ENABLED", False) + + response = await client.post( + "/api/v1/stripe/create-checkout-session", + headers=headers, + json={"quantity": 2, "search_space_id": search_space_id}, + ) + + assert response.status_code == 503, response.text + assert response.json()["detail"] == "Page purchases are temporarily unavailable." + + purchase_count = await _fetchrow("SELECT COUNT(*) AS count FROM page_purchases") + assert purchase_count is not None + assert purchase_count["count"] == 0 + + +class TestStripeWebhookFulfillment: + async def test_webhook_grants_pages_once( + self, + client, + headers, + search_space_id: int, + page_limits, + monkeypatch, + ): + await page_limits.set(pages_used=0, pages_limit=100) + + checkout_session = SimpleNamespace( + id="cs_test_webhook_123", + url="https://checkout.stripe.test/cs_test_webhook_123", + payment_intent=None, + amount_total=None, + currency=None, + ) + create_client = _FakeCreateStripeClient(checkout_session) + + monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") + monkeypatch.setattr( + stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" + ) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000) + + create_response = await client.post( + "/api/v1/stripe/create-checkout-session", + headers=headers, + json={"quantity": 3, "search_space_id": search_space_id}, + ) + assert create_response.status_code == 200, create_response.text + + initial_limit = await _get_pages_limit(TEST_EMAIL) + assert initial_limit == 100 + + user_id = await _get_user_id(TEST_EMAIL) + webhook_checkout_session = SimpleNamespace( + id=checkout_session.id, + payment_status="paid", + payment_intent="pi_test_123", + amount_total=300, + currency="usd", + metadata={ + "user_id": user_id, + "quantity": "3", + "pages_per_unit": "1000", + }, + ) + event = SimpleNamespace( + type="checkout.session.completed", + data=SimpleNamespace(object=webhook_checkout_session), + ) + webhook_client = _FakeWebhookStripeClient(event) + + monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: webhook_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_WEBHOOK_SECRET", "whsec_test") + + first_response = await client.post( + "/api/v1/stripe/webhook", + headers={"Stripe-Signature": "sig_test"}, + content=b"{}", + ) + assert first_response.status_code == 200, first_response.text + + updated_limit = await _get_pages_limit(TEST_EMAIL) + assert updated_limit == 3100 + + purchase = await _fetchrow( + """ + SELECT status, amount_total, currency, stripe_payment_intent_id + FROM page_purchases + WHERE stripe_checkout_session_id = $1 + """, + checkout_session.id, + ) + assert purchase is not None + assert purchase["status"] == "COMPLETED" + assert purchase["amount_total"] == 300 + assert purchase["currency"] == "usd" + assert purchase["stripe_payment_intent_id"] == "pi_test_123" + + second_response = await client.post( + "/api/v1/stripe/webhook", + headers={"Stripe-Signature": "sig_test"}, + content=b"{}", + ) + assert second_response.status_code == 200, second_response.text + + assert await _get_pages_limit(TEST_EMAIL) == 3100 + + +class TestStripeReconciliation: + async def test_reconciliation_fulfills_paid_pending_purchase( + self, + client, + headers, + search_space_id: int, + page_limits, + monkeypatch, + ): + await page_limits.set(pages_used=220, pages_limit=150) + + checkout_session = SimpleNamespace( + id="cs_test_reconcile_paid_123", + url="https://checkout.stripe.test/cs_test_reconcile_paid_123", + payment_intent=None, + amount_total=None, + currency=None, + ) + create_client = _FakeCreateStripeClient(checkout_session) + + monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") + monkeypatch.setattr( + stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" + ) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000) + + create_response = await client.post( + "/api/v1/stripe/create-checkout-session", + headers=headers, + json={"quantity": 3, "search_space_id": search_space_id}, + ) + assert create_response.status_code == 200, create_response.text + assert await _get_pages_limit(TEST_EMAIL) == 150 + + reconciled_session = SimpleNamespace( + id=checkout_session.id, + status="complete", + payment_status="paid", + payment_intent="pi_test_reconcile_123", + amount_total=300, + currency="usd", + metadata={}, + ) + reconcile_client = _FakeReconciliationStripeClient(reconciled_session) + + monkeypatch.setattr( + stripe_reconciliation_task, "get_stripe_client", lambda: reconcile_client + ) + monkeypatch.setattr( + stripe_reconciliation_task.config, + "STRIPE_RECONCILIATION_LOOKBACK_MINUTES", + 0, + ) + monkeypatch.setattr( + stripe_reconciliation_task.config, + "STRIPE_RECONCILIATION_BATCH_SIZE", + 20, + ) + + await stripe_reconciliation_task._reconcile_pending_page_purchases() + + assert reconcile_client.requested_ids == [checkout_session.id] + assert await _get_pages_limit(TEST_EMAIL) == 3220 + + purchase = await _fetchrow( + """ + SELECT status, amount_total, currency, stripe_payment_intent_id + FROM page_purchases + WHERE stripe_checkout_session_id = $1 + """, + checkout_session.id, + ) + assert purchase is not None + assert purchase["status"] == "COMPLETED" + assert purchase["amount_total"] == 300 + assert purchase["currency"] == "usd" + assert purchase["stripe_payment_intent_id"] == "pi_test_reconcile_123" + + async def test_reconciliation_marks_expired_pending_purchase_failed( + self, + client, + headers, + search_space_id: int, + page_limits, + monkeypatch, + ): + await page_limits.set(pages_used=0, pages_limit=500) + + checkout_session = SimpleNamespace( + id="cs_test_reconcile_expired_123", + url="https://checkout.stripe.test/cs_test_reconcile_expired_123", + payment_intent=None, + amount_total=None, + currency=None, + ) + create_client = _FakeCreateStripeClient(checkout_session) + + monkeypatch.setattr(stripe_routes, "get_stripe_client", lambda: create_client) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PRICE_ID", "price_pages_1000") + monkeypatch.setattr( + stripe_routes.config, "NEXT_FRONTEND_URL", "http://localhost:3000" + ) + monkeypatch.setattr(stripe_routes.config, "STRIPE_PAGES_PER_UNIT", 1000) + + create_response = await client.post( + "/api/v1/stripe/create-checkout-session", + headers=headers, + json={"quantity": 1, "search_space_id": search_space_id}, + ) + assert create_response.status_code == 200, create_response.text + + expired_session = SimpleNamespace( + id=checkout_session.id, + status="expired", + payment_status="unpaid", + payment_intent=None, + amount_total=100, + currency="usd", + metadata={}, + ) + reconcile_client = _FakeReconciliationStripeClient(expired_session) + + monkeypatch.setattr( + stripe_reconciliation_task, "get_stripe_client", lambda: reconcile_client + ) + monkeypatch.setattr( + stripe_reconciliation_task.config, + "STRIPE_RECONCILIATION_LOOKBACK_MINUTES", + 0, + ) + monkeypatch.setattr( + stripe_reconciliation_task.config, + "STRIPE_RECONCILIATION_BATCH_SIZE", + 20, + ) + + await stripe_reconciliation_task._reconcile_pending_page_purchases() + + assert await _get_pages_limit(TEST_EMAIL) == 500 + + purchase = await _fetchrow( + """ + SELECT status + FROM page_purchases + WHERE stripe_checkout_session_id = $1 + """, + checkout_session.id, + ) + assert purchase is not None + assert purchase["status"] == "FAILED" diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index fcd60980e..d724a0b76 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -7918,6 +7918,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/d3/c238124fbf2dbe5eda203f0a1c4cd6c210e27993ed9780c4c1bf2ab0efbe/static_ffmpeg-3.0-py3-none-any.whl", hash = "sha256:79d9067264cefbb05e6b847be7d6cb7410b7b25adce40178a787f0137567c89f", size = 7927 }, ] +[[package]] +name = "stripe" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/5a/0cdea4b7911b8012936c765544109da27c0728f6911ec7aefe9d59e7a4f9/stripe-15.0.0.tar.gz", hash = "sha256:0717cd9ba8e8193cef8b1c488ce27836754df496ab6fb75864096e0cdf15e52d", size = 1486873 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/4a/4431c998c451cf07f8b4fed98f425b4aaf3d59cc4fb1e6f54d7713606688/stripe-15.0.0-py3-none-any.whl", hash = "sha256:434ec5267a7402a30b76786d159c18d0e138f89195969d6c56bea2e08d353be0", size = 2125454 }, +] + [[package]] name = "surf-new-backend" version = "0.0.13" @@ -7984,6 +7997,7 @@ dependencies = [ { name = "sse-starlette" }, { name = "starlette" }, { name = "static-ffmpeg" }, + { name = "stripe" }, { name = "tavily-python" }, { name = "tornado" }, { name = "trafilatura" }, @@ -8067,6 +8081,7 @@ requires-dist = [ { name = "sse-starlette", specifier = ">=3.1.1,<3.1.2" }, { name = "starlette", specifier = ">=0.40.0,<0.51.0" }, { name = "static-ffmpeg", specifier = ">=2.13" }, + { name = "stripe", specifier = ">=15.0.0" }, { name = "tavily-python", specifier = ">=0.3.2" }, { name = "tornado", specifier = ">=6.5.5" }, { name = "trafilatura", specifier = ">=2.0.0" }, diff --git a/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx new file mode 100644 index 000000000..6cab15d08 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/buy-pages/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { motion } from "motion/react"; +import { BuyPagesContent } from "@/components/settings/buy-pages-content"; + +export default function BuyPagesPage() { + return ( +
+ + + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx new file mode 100644 index 000000000..790227986 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-cancel/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { CircleSlash2 } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function PurchaseCancelPage() { + const params = useParams(); + const searchSpaceId = String(params.search_space_id ?? ""); + + return ( +
+ + + + Checkout canceled + No charge was made and your current pages are unchanged. + + + You can return to the pricing options and try again whenever you're ready. + + + + + + +
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx new file mode 100644 index 000000000..6f46602c6 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/purchase-success/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function PurchaseSuccessPage() { + const params = useParams(); + const queryClient = useQueryClient(); + const searchSpaceId = String(params.search_space_id ?? ""); + + useEffect(() => { + void queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); + }, [queryClient]); + + return ( +
+ + + + Purchase complete + + Your additional pages are being applied to your account now. + + + +

+ Your sidebar usage meter should refresh automatically in a moment. +

+
+ + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx new file mode 100644 index 000000000..833f06201 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Receipt } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; +import { cn } from "@/lib/utils"; + +const STATUS_STYLES: Record = { + completed: { label: "Completed", className: "bg-emerald-600 text-white border-transparent hover:bg-emerald-600" }, + pending: { label: "Pending", className: "bg-yellow-600 text-white border-transparent hover:bg-yellow-600" }, + failed: { label: "Failed", className: "bg-destructive text-white border-transparent hover:bg-destructive" }, +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatAmount(purchase: PagePurchase): string { + if (purchase.amount_total == null) return "—"; + const dollars = purchase.amount_total / 100; + const currency = (purchase.currency ?? "usd").toUpperCase(); + return `$${dollars.toFixed(2)} ${currency}`; +} + +export function PurchaseHistoryContent() { + const { data, isLoading } = useQuery({ + queryKey: ["stripe-purchases"], + queryFn: () => stripeApiService.getPurchases(), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + const purchases = data?.purchases ?? []; + + if (purchases.length === 0) { + return ( +
+ +

No purchases yet

+

+ Your page-pack purchases will appear here after checkout. +

+
+ ); + } + + return ( +
+
+ + + + Date + Pages + Amount + Status + + + + {purchases.map((p) => { + const style = STATUS_STYLES[p.status]; + return ( + + + {formatDate(p.created_at)} + + + {p.pages_granted.toLocaleString()} + + + {formatAmount(p)} + + + + {style.label} + + + + ); + })} + +
+
+

+ Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. +

+
+ ); +} diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 4a32c2147..f727a2018 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useState } from "react"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { queryClient } from "@/lib/query-client/client"; interface DashboardLayoutProps { children: React.ReactNode; @@ -22,6 +24,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { redirectToLogin(); return; } + queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] }); setIsCheckingAuth(false); }, []); diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts index b7289568b..8e196c9c7 100644 --- a/surfsense_web/atoms/user/user-query.atoms.ts +++ b/surfsense_web/atoms/user/user-query.atoms.ts @@ -1,16 +1,15 @@ import { atomWithQuery } from "jotai-tanstack-query"; import { userApiService } from "@/lib/apis/user-api.service"; -import { getBearerToken, isPublicRoute } from "@/lib/auth-utils"; +import { getBearerToken } from "@/lib/auth-utils"; export const USER_QUERY_KEY = ["user", "me"] as const; const userQueryFn = () => userApiService.getMe(); export const currentUserAtom = atomWithQuery(() => { - const pathname = typeof window !== "undefined" ? window.location.pathname : null; return { queryKey: USER_QUERY_KEY, staleTime: 5 * 60 * 1000, - enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname), + enabled: !!getBearerToken(), queryFn: userQueryFn, }; }); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 76c0389b9..4eff8d546 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -15,7 +15,6 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { - morePagesDialogAtom, searchSpaceSettingsDialogAtom, teamDialogAtom, userSettingsDialogAtom, @@ -27,7 +26,6 @@ import { type Tab, } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { MorePagesDialog } from "@/components/settings/more-pages-dialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { TeamDialog } from "@/components/settings/team-dialog"; import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; @@ -203,8 +201,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid const seenPageLimitNotifications = useRef>(new Set()); const isInitialLoad = useRef(true); - const setMorePagesOpen = useSetAtom(morePagesDialogAtom); - // Effect to show toast for new page_limit_exceeded notifications useEffect(() => { if (statusInbox.loading) return; @@ -233,12 +229,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid duration: 8000, icon: , action: { - label: "View Plans", - onClick: () => setMorePagesOpen(true), + label: "Get More Pages", + onClick: () => router.push(`/dashboard/${searchSpaceId}/more-pages`), }, }); } - }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]); + }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]); // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); @@ -923,7 +919,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid - ); } diff --git a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx index 14e75e176..32532254d 100644 --- a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx +++ b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx @@ -1,10 +1,12 @@ "use client"; -import { useSetAtom } from "jotai"; -import { Zap } from "lucide-react"; -import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; +import { useQuery } from "@tanstack/react-query"; +import { CreditCard, Zap } from "lucide-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; interface PageUsageDisplayProps { pagesUsed: number; @@ -12,12 +14,18 @@ interface PageUsageDisplayProps { } export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { - const setMorePagesOpen = useSetAtom(morePagesDialogAtom); + const params = useParams(); + const searchSpaceId = params?.search_space_id ?? ""; const usagePercentage = (pagesUsed / pagesLimit) * 100; + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true; return (
-
+
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages @@ -25,19 +33,32 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp {usagePercentage.toFixed(0)}%
- + + {pageBuyingEnabled && ( + + + + Buy Pages + + + $1/1k + + + )}
); diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index ce7b06da6..af1c9bd59 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -8,11 +8,12 @@ const demoPlans = [ price: "0", yearlyPrice: "0", period: "", - billingText: "", + billingText: "1,000 pages included", features: [ "Self Hostable", - "Upload and chat with 300+ pages of content", - "Includes access to ChatGPT text and audio models", + "1,000 pages included to start", + "Earn up to 6,000+ bonus pages for free", + "Includes access to OpenAI text, audio and image models", "Realtime Collaborative Group Chats with teammates", "Community support on Discord", ], @@ -22,21 +23,20 @@ const demoPlans = [ isPopular: false, }, { - name: "PRO", - price: "0", - yearlyPrice: "0", - period: "", - billingText: "Free during beta", + name: "PAY AS YOU GO", + price: "1", + yearlyPrice: "1", + period: "1,000 pages", + billingText: "No subscription, buy only when you need more", features: [ "Everything in Free", - "Includes 6000+ pages of content", - "Access to more models and providers", + "Buy 1,000-page packs at $1 each", "Priority support on Discord", ], description: "", buttonText: "Get Started", href: "/login", - isPopular: true, + isPopular: false, }, { name: "ENTERPRISE", @@ -45,7 +45,7 @@ const demoPlans = [ period: "", billingText: "", features: [ - "Everything in Pro", + "Everything in Pay As You Go", "On-prem or VPC deployment", "Audit logs and compliance", "SSO, OIDC & SAML", @@ -63,7 +63,11 @@ const demoPlans = [ function PricingBasic() { return ( - + ); } diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index f4df921f3..c97eac072 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -14,7 +14,7 @@ import { schema } from "@/zero/schema"; const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848"; -function ZeroAuthGuard({ children }: { children: React.ReactNode }) { +function ZeroAuthSync() { const zero = useZero(); const connectionState = useConnectionState(); const isRefreshingRef = useRef(false); @@ -37,7 +37,7 @@ function ZeroAuthGuard({ children }: { children: React.ReactNode }) { }); }, [connectionState, zero]); - return <>{children}; + return null; } export function ZeroProvider({ children }: { children: React.ReactNode }) { @@ -59,7 +59,8 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) { return ( - {hasUser ? {children} : children} + {hasUser && } + {children} ); } diff --git a/surfsense_web/components/settings/buy-pages-content.tsx b/surfsense_web/components/settings/buy-pages-content.tsx new file mode 100644 index 000000000..6aeb44ea2 --- /dev/null +++ b/surfsense_web/components/settings/buy-pages-content.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Minus, Plus } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; +import { AppError } from "@/lib/error"; +import { cn } from "@/lib/utils"; + +const PAGE_PACK_SIZE = 1000; +const PRICE_PER_PACK_USD = 1; +const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const; + +export function BuyPagesContent() { + const params = useParams(); + const [quantity, setQuantity] = useState(1); + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + + const purchaseMutation = useMutation({ + mutationFn: stripeApiService.createCheckoutSession, + onSuccess: (response) => { + window.location.assign(response.checkout_url); + }, + onError: (error) => { + if (error instanceof AppError && error.message) { + toast.error(error.message); + return; + } + toast.error("Failed to start checkout. Please try again."); + }, + }); + + const searchSpaceId = Number(params.search_space_id); + const hasValidSearchSpace = Number.isFinite(searchSpaceId) && searchSpaceId > 0; + const totalPages = quantity * PAGE_PACK_SIZE; + const totalPrice = quantity * PRICE_PER_PACK_USD; + + if (stripeStatus && !stripeStatus.page_buying_enabled) { + return ( +
+

Buy Pages

+

+ Page purchases are temporarily unavailable. +

+
+ ); + } + + const handleBuyNow = () => { + if (!hasValidSearchSpace) { + toast.error("Unable to determine the current workspace for checkout."); + return; + } + purchaseMutation.mutate({ + quantity, + search_space_id: searchSpaceId, + }); + }; + + return ( +
+
+

Buy Pages

+

+ $1 per 1,000 pages, pay as you go +

+
+ +
+ {/* Stepper */} +
+ + + {totalPages.toLocaleString()} + + +
+ + {/* Quick-pick presets */} +
+ {PRESET_MULTIPLIERS.map((m) => ( + + ))} +
+ +
+ {totalPages.toLocaleString()} pages + ${totalPrice} +
+ + +

+ Secure checkout via Stripe +

+
+
+ ); +} diff --git a/surfsense_web/components/settings/more-pages-content.tsx b/surfsense_web/components/settings/more-pages-content.tsx index bf0c9924f..b076b4fdc 100644 --- a/surfsense_web/components/settings/more-pages-content.tsx +++ b/surfsense_web/components/settings/more-pages-content.tsx @@ -1,20 +1,16 @@ "use client"; -import { IconCalendar, IconMailFilled } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react"; +import { Check, ExternalLink, Mail } from "lucide-react"; import Link from "next/link"; -import { useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; +import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { Button } from "@/components/ui/button"; import { Card, CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, } from "@/components/ui/card"; import { Dialog, @@ -22,15 +18,14 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types"; import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service"; +import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { - trackIncentiveContactOpened, trackIncentivePageViewed, trackIncentiveTaskClicked, trackIncentiveTaskCompleted, @@ -38,7 +33,10 @@ import { import { cn } from "@/lib/utils"; export function MorePagesContent() { + const params = useParams(); const queryClient = useQueryClient(); + const searchSpaceId = params?.search_space_id ?? ""; + const [claimOpen, setClaimOpen] = useState(false); useEffect(() => { trackIncentivePageViewed(); @@ -48,6 +46,11 @@ export function MorePagesContent() { queryKey: ["incentive-tasks"], queryFn: () => incentiveTasksApiService.getTasks(), }); + const { data: stripeStatus } = useQuery({ + queryKey: ["stripe-status"], + queryFn: () => stripeApiService.getStatus(), + }); + const pageBuyingEnabled = stripeStatus?.page_buying_enabled ?? true; const completeMutation = useMutation({ mutationFn: incentiveTasksApiService.completeTask, @@ -59,7 +62,7 @@ export function MorePagesContent() { trackIncentiveTaskCompleted(taskType, task.pages_reward); } queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] }); - queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY }); } }, onError: () => { @@ -75,132 +78,138 @@ export function MorePagesContent() { }; return ( -
+
- -

Get More Pages

+

Get Free Pages

- Complete tasks to earn additional pages + Claim your free page offer and earn bonus pages

- {isLoading ? ( - - - -
- - -
- -
-
- ) : ( -
- {data?.tasks.map((task) => ( - - -
- {task.completed ? : } -
-
+ {/* 6k free offer */} + + +
+ 6k +
+
+

Claim 6,000 Free Pages

+

+ Limited offer. Schedule a meeting or email us to claim. +

+
+ +
+
+ + + + {/* Free tasks */} +
+

Earn Bonus Pages

+ {isLoading ? ( + + + +
+ +
+ +
+
+ ) : ( +
+ {data?.tasks.map((task) => ( + + +
+ {task.completed ? : +{task.pages_reward}} +

{task.title}

-

+{task.pages_reward} pages

-
- - - - ))} -
- )} + + + + ))} +
+ )} +
- - -
- - Upgrade to PRO - - FREE - -
- - For a limited time, get{" "} - 6,000 additional pages at no - cost. Contact us and we'll upgrade your account instantly. - -
- - open && trackIncentiveContactOpened()}> - - - - - - Get in Touch - Pick the option that works best for you. - -
- - -
-
-
-
-
+ {/* Link to buy pages */} +
+

Need more?

+ {pageBuyingEnabled ? ( + + ) : ( +

+ Page purchases are temporarily unavailable. +

+ )} +
+ + {/* Claim 6k dialog */} + + + + Claim 6,000 Free Pages + + Send us an email to claim your free 6,000 pages. Include your account email and primary usecase for free pages. + + + + +
); } diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx index 3a66c54de..389ebc5fd 100644 --- a/surfsense_web/components/settings/user-settings-dialog.tsx +++ b/surfsense_web/components/settings/user-settings-dialog.tsx @@ -1,12 +1,13 @@ "use client"; import { useAtom } from "jotai"; -import { Globe, KeyRound, Sparkles, User } from "lucide-react"; +import { Globe, KeyRound, Receipt, Sparkles, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent"; import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"; import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent"; import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent"; +import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { SettingsDialog } from "@/components/settings/settings-dialog"; @@ -31,6 +32,11 @@ export function UserSettingsDialog() { label: "Community Prompts", icon: , }, + { + value: "purchases", + label: "Purchase History", + icon: , + }, ]; return ( @@ -47,6 +53,7 @@ export function UserSettingsDialog() { {state.initialTab === "api-key" && } {state.initialTab === "prompts" && } {state.initialTab === "community-prompts" && } + {state.initialTab === "purchases" && }
); diff --git a/surfsense_web/contracts/types/stripe.types.ts b/surfsense_web/contracts/types/stripe.types.ts new file mode 100644 index 000000000..c7a6bf387 --- /dev/null +++ b/surfsense_web/contracts/types/stripe.types.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const pagePurchaseStatusEnum = z.enum(["pending", "completed", "failed"]); + +export const createCheckoutSessionRequest = z.object({ + quantity: z.number().int().min(1).max(100), + search_space_id: z.number().int().min(1), +}); + +export const createCheckoutSessionResponse = z.object({ + checkout_url: z.string(), +}); + +export const stripeStatusResponse = z.object({ + page_buying_enabled: z.boolean(), +}); + +export const pagePurchase = z.object({ + id: z.uuid(), + stripe_checkout_session_id: z.string(), + stripe_payment_intent_id: z.string().nullable(), + quantity: z.number(), + pages_granted: z.number(), + amount_total: z.number().nullable(), + currency: z.string().nullable(), + status: pagePurchaseStatusEnum, + completed_at: z.string().nullable(), + created_at: z.string(), +}); + +export const getPagePurchasesResponse = z.object({ + purchases: z.array(pagePurchase), +}); + +export type PagePurchaseStatus = z.infer; +export type CreateCheckoutSessionRequest = z.infer; +export type CreateCheckoutSessionResponse = z.infer; +export type StripeStatusResponse = z.infer; +export type PagePurchase = z.infer; +export type GetPagePurchasesResponse = z.infer; diff --git a/surfsense_web/lib/apis/stripe-api.service.ts b/surfsense_web/lib/apis/stripe-api.service.ts new file mode 100644 index 000000000..f0d927ebf --- /dev/null +++ b/surfsense_web/lib/apis/stripe-api.service.ts @@ -0,0 +1,30 @@ +import { + type CreateCheckoutSessionRequest, + type CreateCheckoutSessionResponse, + createCheckoutSessionResponse, + type GetPagePurchasesResponse, + getPagePurchasesResponse, + type StripeStatusResponse, + stripeStatusResponse, +} from "@/contracts/types/stripe.types"; +import { baseApiService } from "./base-api.service"; + +class StripeApiService { + createCheckoutSession = async ( + request: CreateCheckoutSessionRequest + ): Promise => { + return baseApiService.post("/api/v1/stripe/create-checkout-session", createCheckoutSessionResponse, { + body: request, + }); + }; + + getPurchases = async (): Promise => { + return baseApiService.get("/api/v1/stripe/purchases", getPagePurchasesResponse); + }; + + getStatus = async (): Promise => { + return baseApiService.get("/api/v1/stripe/status", stripeStatusResponse); + }; +} + +export const stripeApiService = new StripeApiService(); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index e36aff10a..baa371e05 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -45,3 +45,4 @@ export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted"; // Helper to check if running in cloud mode export const isCloud = () => DEPLOYMENT_MODE === "cloud"; +