diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index 08d34eb..89c91fc 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -29,6 +29,10 @@ "task": "app.tasks.digest.send_daily_digest", "schedule": crontab(hour=12, minute=30), # 6:00 PM IST = 12:30 PM UTC }, + "reconcile-usage-periods": { + "task": "reconcile_usage_periods", + "schedule": crontab(hour=2, minute=0), # 2:00 AM UTC daily + }, } diff --git a/backend/app/main.py b/backend/app/main.py index ab8d9d6..6d12fd2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from app.middleware.setup import SecurityHeadersMiddleware, RequestLoggingMiddleware from app.middleware.logging import setup_logging from app.middleware.cors import WildcardCORSMiddleware -from app.routers import auth, workspace, jobs, resumes, ai, billing, extension, webhooks, files, health, users +from app.routers import auth, workspace, jobs, resumes, ai, builder, billing, extension, webhooks, files, health, admin, users setup_logging() logger = structlog.get_logger() @@ -48,10 +48,12 @@ async def lifespan(app: FastAPI): app.include_router(jobs.router) app.include_router(resumes.router) app.include_router(ai.router) +app.include_router(builder.router) app.include_router(billing.router) app.include_router(extension.router) app.include_router(webhooks.router) app.include_router(files.router) +app.include_router(admin.router) @app.get("/extension-version.json") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c6458ef..f3d0eb2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,6 +12,7 @@ from app.models.builder_resume import BuilderResume, BuilderResumeVersion from app.models.download_log import DownloadLog from app.models.prompt_log import PromptLog +from app.models.usage import UserUsage __all__ = [ "User", @@ -37,4 +38,5 @@ "BuilderResumeVersion", "DownloadLog", "PromptLog", + "UserUsage", ] diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index 7905200..2dc4709 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -26,6 +26,10 @@ class Subscription(Base): suspended: Mapped[bool] = mapped_column(Boolean, default=False) lifetime_deal: Mapped[bool] = mapped_column(Boolean, default=False) provider: Mapped[str] = mapped_column(String, default="razorpay") + last_payment_status: Mapped[str | None] = mapped_column(String, nullable=True) + last_payment_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + paused_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) @@ -39,6 +43,7 @@ class PaymentEvent(Base): provider: Mapped[str] = mapped_column(String, default="razorpay") event_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) event_type: Mapped[str] = mapped_column(String, nullable=False) - workspace_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False) + workspace_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=True) + user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) payload: Mapped[dict] = mapped_column(JSONB, default={}) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/usage.py b/backend/app/models/usage.py new file mode 100644 index 0000000..b681dae --- /dev/null +++ b/backend/app/models/usage.py @@ -0,0 +1,20 @@ +import uuid +from datetime import date, datetime +from sqlalchemy import Date, Integer, DateTime, ForeignKey, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column +from app.database import Base + + +class UserUsage(Base): + __tablename__ = "user_usage" + __table_args__ = (UniqueConstraint("user_id", "period_start", name="uq_user_usage_user_period"),) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + period_start: Mapped[date] = mapped_column(Date, nullable=False) + period_end: Mapped[date] = mapped_column(Date, nullable=False) + job_tracks_used: Mapped[int] = mapped_column(Integer, default=0) + cover_letters_used: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..474dc07 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies.auth import require_admin +from app.schemas.admin import ( + AdminOverviewOut, + AdminSubscriptionOut, + AdminUsageRowOut, + RevenuePointOut, +) +from app.services import admin as admin_service + +router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) + + +@router.get("/stats/overview", response_model=AdminOverviewOut, dependencies=[Depends(require_admin)]) +async def stats_overview(db: AsyncSession = Depends(get_db)): + return await admin_service.get_overview(db) + + +@router.get("/stats/revenue", response_model=list[RevenuePointOut], dependencies=[Depends(require_admin)]) +async def stats_revenue(days: int = 30, db: AsyncSession = Depends(get_db)): + return await admin_service.get_revenue_series(db, days=days) + + +@router.get("/subscriptions", response_model=list[AdminSubscriptionOut], dependencies=[Depends(require_admin)]) +async def list_subscriptions(limit: int = 50, offset: int = 0, db: AsyncSession = Depends(get_db)): + return await admin_service.list_subscriptions(db, limit=limit, offset=offset) + + +@router.get("/usage", response_model=list[AdminUsageRowOut], dependencies=[Depends(require_admin)]) +async def list_usage(limit: int = 50, db: AsyncSession = Depends(get_db)): + return await admin_service.list_usage(db, limit=limit) diff --git a/backend/app/routers/ai.py b/backend/app/routers/ai.py index 8594e84..8872434 100644 --- a/backend/app/routers/ai.py +++ b/backend/app/routers/ai.py @@ -15,9 +15,12 @@ AtsScoreRequest, AtsScoreResult, ) +from app.schemas.usage import PlanLimitErrorOut from app.services import ai as ai_service from app.services import workspace as workspace_service +from app.services import usage as usage_service from app.services import user as user_service +from app.services import builder as builder_service from app.models.job import Job router = APIRouter(prefix="/api/v1/ai", tags=["ai"]) @@ -66,6 +69,25 @@ def _parse_resume_chat(content: str) -> tuple[str, str | None]: return answer, updated +def _compute_section_completion(content: dict) -> dict: + """Deterministic Section Completion fallback when the model omits it from builder_score.""" + contact = content.get("contact") or {} + checks = [ + ("Contact", bool(contact.get("name") or contact.get("email"))), + ("Summary", bool(content.get("about"))), + ("Skills", bool(content.get("skills"))), + ("Experience", bool(content.get("work"))), + ("Education", bool(content.get("education"))), + ("Projects", bool(content.get("projects"))), + ("Certifications", bool(content.get("certifications"))), + ] + ok_count = sum(1 for _, ok in checks if ok) + return { + "score": round(100 * ok_count / len(checks)) if checks else 0, + "checks": [{"label": label, "ok": ok_flag} for label, ok_flag in checks], + } + + async def _log_ai_call(db, workspace_id, user_id, job_id, res, purpose): await ai_service.log_ai_cost( db, @@ -97,13 +119,36 @@ async def ai_generate( db: AsyncSession = Depends(get_db), ): ws = await get_workspace_info(user, db) - - allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id) - if not allowed: - raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached") + user_id = uuid.UUID(user["user_id"]) + + is_cover_letter = data.prompt_name == "generate_cover_letter" + if is_cover_letter: + check = await usage_service.can_generate_cover_letter(db, user_id) + if not check.allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=PlanLimitErrorOut( + message=f"You've reached your {check.plan_name} plan's cover letter limit. Upgrade to generate more.", + currentPlan=check.plan_name, + limit=check.limit, + used=check.used, + ).model_dump(), + ) render_vars = dict(data.vars) + # Builder prompts expect {{title}} / {{jd}} / {{resume_json}}, but the frontend only sends + # vars:{job_id}. Resolve them server-side from the Job and seeded BuilderResume. + BUILDER_PROMPTS = {"builder_job_match", "builder_score", "builder_suggestions"} + if data.prompt_name in BUILDER_PROMPTS and data.job_id: + job = ( + await db.execute(select(Job).where(Job.id == uuid.UUID(data.job_id))) + ).scalar_one_or_none() + builder = await builder_service.get_or_create_builder(db, ws.id, uuid.UUID(data.job_id)) + render_vars.setdefault("title", job.title if job else "") + render_vars.setdefault("jd", (job.description or "") if job else "") + render_vars.setdefault("resume_json", json.dumps(builder.content, default=str)) + profile = await user_service.get_profile(db, uuid.UUID(user["user_id"])) profile_block = _build_profile_block(profile) @@ -159,6 +204,28 @@ async def ai_generate( except (json.JSONDecodeError, ValueError): pass + if is_cover_letter: + await usage_service.increment_cover_letter_usage(db, user_id) + + # Persist builder analysis back onto the BuilderResume so /seed refetch (triggered by the + # frontend's query invalidate) returns the latest Job Match / Score / Suggestions. + if data.job_id and data.prompt_name in BUILDER_PROMPTS: + builder = await builder_service.get_or_create_builder(db, ws.id, uuid.UUID(data.job_id)) + try: + parsed = json.loads(result["content"]) + if isinstance(parsed, dict): + if data.prompt_name == "builder_score" and not parsed.get("section"): + parsed["section"] = _compute_section_completion(builder.content) + if data.prompt_name == "builder_job_match": + builder.job_match = parsed + elif data.prompt_name == "builder_score": + builder.score = parsed + elif data.prompt_name == "builder_suggestions": + builder.suggestions = parsed + await db.flush() + except (json.JSONDecodeError, ValueError): + pass + # Tailored resumes: the whole-document call tends to update Skills but leave Experience # bullets untouched. Run a focused, best-effort rewrite of the Experience section and splice it in. if data.prompt_name == "tailor_resume": @@ -212,10 +279,6 @@ async def edit_resume( ): ws = await get_workspace_info(user, db) - allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id) - if not allowed: - raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached") - profile = await user_service.get_profile(db, uuid.UUID(user["user_id"])) profile_block = _build_profile_block(profile) @@ -240,10 +303,6 @@ async def ats_score( ): ws = await get_workspace_info(user, db) - allowed = await ai_service.check_entitlement(db, str(ws.id), data.job_id) - if not allowed: - raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Free trial limit reached") - job = (await db.execute(select(Job).where(Job.id == uuid.UUID(data.job_id)))).scalar_one_or_none() if not job: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") @@ -311,6 +370,11 @@ async def check_entitlement( user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - ws = await get_workspace_info(user, db) - allowed = await ai_service.check_entitlement(db, str(ws.id), job_id) - return {"allowed": allowed} + user_id = uuid.UUID(user["user_id"]) + job_tracks = await usage_service.can_create_job_track(db, user_id) + cover_letters = await usage_service.can_generate_cover_letter(db, user_id) + return { + "allowed": job_tracks.allowed and cover_letters.allowed, + "job_track": {"allowed": job_tracks.allowed, "used": job_tracks.used, "limit": job_tracks.limit}, + "cover_letter": {"allowed": cover_letters.allowed, "used": cover_letters.used, "limit": cover_letters.limit}, + } diff --git a/backend/app/routers/billing.py b/backend/app/routers/billing.py index 747f533..26207d2 100644 --- a/backend/app/routers/billing.py +++ b/backend/app/routers/billing.py @@ -1,8 +1,7 @@ import uuid import base64 -import hashlib import json -from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -10,27 +9,18 @@ from app.schemas.billing import ( BillingStatusOut, CreateSubscriptionRequest, + CancelSubscriptionRequest, SubscriptionOut, + BillingHistoryItemOut, PricingOut, ) -from app.schemas.extension import ( - ExtensionTokenCreate, - ExtensionTokenOut, - ExtensionTokenCreatedOut, - ExtensionJobCreate, -) -from app.services import workspace as workspace_service -from app.services import jobs as jobs_service -from app.models.job import JobStatus +from app.schemas.usage import UsageOut +from app.services import subscription as subscription_service +from app.services import usage as usage_service from app.config import settings -from app.models.extension_token import ExtensionToken from app.models.subscription import Subscription from app.models.plan import Plan, GeoPricing -from app.models.workspace import Workspace -from app.models.job import JobArtifact, ArtifactKind -from app.models.board import Board -from sqlalchemy import select, delete -from datetime import datetime, timezone +from sqlalchemy import select import httpx router = APIRouter(prefix="/api/v1/billing", tags=["billing"]) @@ -41,54 +31,33 @@ async def billing_status( user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - ws = await workspace_service.get_workspace_for_user(db, uuid.UUID(user["user_id"])) - if not ws: - return BillingStatusOut( - plan="free", - currency="USD", - trial_used=0, - trial_limit=2, - has_pro=False, - ) - - from app.models.workspace import Workspace as WsModel - from app.models.subscription import Subscription as SubModel + user_id = uuid.UUID(user["user_id"]) + sub = await subscription_service.get_subscription_for_user(db, user_id) + plan = await usage_service.get_active_plan(db, user_id, sub=sub) + job_tracks = await usage_service.can_create_job_track(db, user_id) - ws_result = await db.execute( - select(WsModel).where(WsModel.owner_user_id == uuid.UUID(user["user_id"])).limit(1) - ) - ws = ws_result.scalar_one_or_none() - - is_pro = False - if ws: - sub_result = await db.execute(select(SubModel).where(SubModel.user_id == uuid.UUID(user["user_id"]))) - sub = sub_result.scalar_one_or_none() - if sub and sub.subscription_status == "active" and not sub.suspended: - from datetime import timezone as tz - if sub.current_period_end is None or sub.current_period_end > datetime.now(tz.utc): - is_pro = True - - art_result = await db.execute( - select(JobArtifact) - .where(JobArtifact.workspace_id == ws.id) - .where(JobArtifact.kind.in_([ArtifactKind.TAILORED_RESUME, ArtifactKind.COVER_LETTER])) - ) - arts = list(art_result.scalars().all()) - distinct_jobs = set(str(a.job_id) for a in arts if a.job_id) - - sub_result = await db.execute(select(SubModel).where(SubModel.user_id == uuid.UUID(user["user_id"]))) - sub = sub_result.scalar_one_or_none() + is_pro = plan.id != "free" return BillingStatusOut( - plan=ws.plan if ws else "free", - currency=ws.currency if ws else "USD", - trial_used=len(distinct_jobs), - trial_limit=int(ws.trial_apps_limit) if ws else 2, + plan=plan.id, + currency="USD", + trial_used=job_tracks.used, + trial_limit=job_tracks.limit if job_tracks.limit is not None else -1, has_pro=is_pro, current_period_end=sub.current_period_end if sub else None, + cancel_at_period_end=bool(sub.cancel_at_period_end) if sub else False, ) +@router.get("/usage", response_model=UsageOut) +async def get_usage( + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + snapshot = await usage_service.get_usage_snapshot(db, uuid.UUID(user["user_id"])) + return UsageOut(**snapshot) + + @router.post("/subscription/create") async def create_subscription( data: CreateSubscriptionRequest, @@ -101,28 +70,16 @@ async def create_subscription( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Razorpay not configured") country_code = data.country_code.upper() + user_id = uuid.UUID(user["user_id"]) - pricing_result = await db.execute( - select(GeoPricing) - .where(GeoPricing.plan_id == data.plan_id) - .where(GeoPricing.country_code.in_([country_code, "DEFAULT"])) - ) - rows = list(pricing_result.scalars().all()) - chosen = next((r for r in rows if r.country_code == country_code), None) - if not chosen: - chosen = next((r for r in rows if r.country_code == "DEFAULT"), None) - - razorpay_plan_id = ( - chosen.razorpay_plan_id_annual if data.billing_cycle == "annual" else chosen.razorpay_plan_id_monthly + # Server-side resolution only — never trust a client-supplied plan/price. + razorpay_plan_id = await subscription_service.resolve_razorpay_plan_id( + db, data.plan_id, data.billing_cycle, country_code ) if not razorpay_plan_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan not available in your region") - existing_result = await db.execute( - select(Subscription).where(Subscription.user_id == uuid.UUID(user["user_id"])) - ) - existing = existing_result.scalar_one_or_none() - + existing = await subscription_service.get_subscription_for_user(db, user_id) if existing and existing.razorpay_subscription_id and existing.subscription_status == "active": return { "subscription_id": existing.razorpay_subscription_id, @@ -136,7 +93,7 @@ async def create_subscription( "total_count": 10 if data.billing_cycle == "annual" else 120, "customer_notify": 1, "notes": { - "user_id": user["user_id"], + "user_id": str(user_id), "plan_id": data.plan_id, "billing_cycle": data.billing_cycle, "country_code": country_code, @@ -159,24 +116,9 @@ async def create_subscription( rp_data = json.loads(text) rp_sub_id = rp_data.get("id") - if existing: - existing.plan = data.plan_id - existing.plan_id = data.plan_id - existing.razorpay_subscription_id = rp_sub_id - existing.subscription_status = rp_data.get("status") - existing.billing_cycle = data.billing_cycle - else: - sub = Subscription( - user_id=uuid.UUID(user["user_id"]), - plan=data.plan_id, - plan_id=data.plan_id, - razorpay_subscription_id=rp_sub_id, - subscription_status=rp_data.get("status"), - billing_cycle=data.billing_cycle, - ) - db.add(sub) - - await db.flush() + await subscription_service.create_or_update_subscription_from_razorpay( + db, user_id, data.plan_id, data.billing_cycle, rp_sub_id, rp_data.get("status") + ) return { "subscription_id": rp_sub_id, @@ -187,6 +129,35 @@ async def create_subscription( } +@router.post("/subscription/cancel", response_model=SubscriptionOut) +async def cancel_subscription( + data: CancelSubscriptionRequest, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + sub = await subscription_service.cancel_subscription(db, uuid.UUID(user["user_id"]), data.at_period_end) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + return sub + + +@router.post("/subscription/reactivate", response_model=SubscriptionOut) +async def reactivate_subscription( + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + try: + sub = await subscription_service.reactivate_subscription(db, uuid.UUID(user["user_id"])) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + return sub + + @router.get("/subscription", response_model=SubscriptionOut | None) async def get_subscription( user: dict = Depends(get_current_user), @@ -196,6 +167,35 @@ async def get_subscription( return result.scalar_one_or_none() +_EVENT_SUMMARIES = { + "subscription.activated": "Subscription activated", + "subscription.charged": "Payment charged — subscription renewed", + "subscription.completed": "Subscription completed", + "subscription.cancelled": "Subscription cancelled", + "subscription.paused": "Subscription paused", + "subscription.resumed": "Subscription resumed", + "payment.authorized": "Payment authorized", + "payment.failed": "Payment failed", +} + + +@router.get("/subscription/history", response_model=list[BillingHistoryItemOut]) +async def get_billing_history( + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + events = await subscription_service.list_billing_history(db, uuid.UUID(user["user_id"])) + return [ + BillingHistoryItemOut( + id=e.id, + event_type=e.event_type, + created_at=e.created_at, + summary=_EVENT_SUMMARIES.get(e.event_type, e.event_type), + ) + for e in events + ] + + @router.get("/pricing", response_model=list[PricingOut]) async def get_pricing( country_code: str = "DEFAULT", diff --git a/backend/app/routers/builder.py b/backend/app/routers/builder.py new file mode 100644 index 0000000..a19ac87 --- /dev/null +++ b/backend/app/routers/builder.py @@ -0,0 +1,113 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies.auth import get_current_user +from app.schemas.ai import BuilderSeedRequest, BuilderSaveRequest, BuilderCore, BuilderOut +from app.models.job import Job +from app.services import builder as builder_service +from app.services import workspace as workspace_service + +router = APIRouter(prefix="/api/v1/builder", tags=["builder"]) + + +async def get_workspace_id(user: dict, db: AsyncSession) -> uuid.UUID: + ws = await workspace_service.get_workspace_for_user(db, uuid.UUID(user["user_id"])) + if not ws: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found") + return ws.id + + +async def _ensure_job(db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID) -> None: + result = await db.execute( + select(Job).where(Job.id == job_id, Job.workspace_id == workspace_id).limit(1) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found") + + +@router.post("/seed", response_model=BuilderOut) +async def seed_builder( + data: BuilderSeedRequest, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + job_id = uuid.UUID(data.job_id) + await _ensure_job(db, ws_id, job_id) + builder = await builder_service.seed_builder(db, ws_id, job_id) + return BuilderOut( + content=builder.content, + latex_source=builder.latex_source, + job_match=builder.job_match, + score=builder.score, + suggestions=builder.suggestions, + ) + + +@router.put("/{job_id}") +async def save_builder( + job_id: uuid.UUID, + data: BuilderSaveRequest, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + await _ensure_job(db, ws_id, job_id) + await builder_service.save_builder(db, ws_id, job_id, data.content) + return {"ok": True} + + +@router.post("/{job_id}/undo", response_model=BuilderCore) +async def undo_builder( + job_id: uuid.UUID, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + await _ensure_job(db, ws_id, job_id) + content, latex_source = await builder_service.undo_builder(db, ws_id, job_id) + return BuilderCore(content=content, latex_source=latex_source) + + +@router.post("/{job_id}/suggestions/{suggestion_id}/apply", response_model=BuilderCore) +async def apply_suggestion( + job_id: uuid.UUID, + suggestion_id: str, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + await _ensure_job(db, ws_id, job_id) + try: + builder = await builder_service.apply_suggestion(db, ws_id, job_id, suggestion_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + return BuilderCore(content=builder.content, latex_source=builder.latex_source) + + +@router.post("/{job_id}/suggestions/{suggestion_id}/ignore") +async def ignore_suggestion( + job_id: uuid.UUID, + suggestion_id: str, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + await _ensure_job(db, ws_id, job_id) + await builder_service.ignore_suggestion(db, ws_id, job_id, suggestion_id) + return {"ok": True} + + +@router.post("/{job_id}/save-as-base") +async def save_as_base( + job_id: uuid.UUID, + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ws_id = await get_workspace_id(user, db) + await _ensure_job(db, ws_id, job_id) + await builder_service.save_as_base(db, ws_id, job_id) + return {"ok": True} diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py index da4c7a4..83679d4 100644 --- a/backend/app/routers/jobs.py +++ b/backend/app/routers/jobs.py @@ -19,12 +19,28 @@ from app.models.job import Job, JobStatus, ArtifactKind from app.services import jobs as jobs_service from app.services import workspace as workspace_service +from app.services import usage as usage_service +from app.schemas.usage import PlanLimitErrorOut from app.services import company as company_service from pydantic import BaseModel router = APIRouter(prefix="/api/v1/jobs", tags=["jobs"]) +async def _raise_if_job_track_blocked(db: AsyncSession, user_id: uuid.UUID) -> None: + check = await usage_service.can_create_job_track(db, user_id) + if not check.allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=PlanLimitErrorOut( + message=f"You've reached your {check.plan_name} plan's job track limit. Upgrade to add more.", + currentPlan=check.plan_name, + limit=check.limit, + used=check.used, + ).model_dump(), + ) + + async def get_workspace_id(user: dict, db: AsyncSession) -> uuid.UUID: ws = await workspace_service.get_workspace_for_user(db, uuid.UUID(user["user_id"])) if not ws: @@ -199,7 +215,11 @@ async def create_job( user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + user_id = uuid.UUID(user["user_id"]) ws_id = await get_workspace_id(user, db) + + await _raise_if_job_track_blocked(db, user_id) + job = await jobs_service.create_job( db, workspace_id=ws_id, @@ -215,6 +235,7 @@ async def create_job( resume_score=data.resume_score, company_domain=data.company_domain, ) + await usage_service.increment_job_track_usage(db, user_id) await db.refresh(job) return job diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py index 06a25c6..a2bf665 100644 --- a/backend/app/routers/webhooks.py +++ b/backend/app/routers/webhooks.py @@ -1,14 +1,13 @@ import json import hmac import hashlib -import base64 from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.config import settings -from app.models.subscription import Subscription, PaymentEvent -from app.models.workspace import Workspace +from app.models.subscription import PaymentEvent +from app.services.webhook import WEBHOOK_HANDLERS, record_payment_event from sqlalchemy import select router = APIRouter(prefix="/api/v1/webhooks", tags=["webhooks"]) @@ -37,6 +36,7 @@ async def razorpay_webhook(request: Request, db: AsyncSession = Depends(get_db)) event_id = payload.get("event_id", "") event_type = payload.get("event", "") + # Idempotency: skip if we've already processed this exact event. if event_id: existing = await db.execute( select(PaymentEvent).where(PaymentEvent.event_id == event_id) @@ -44,45 +44,9 @@ async def razorpay_webhook(request: Request, db: AsyncSession = Depends(get_db)) if existing.scalar_one_or_none(): return {"ok": True, "skipped": "duplicate"} - sub_entity = (payload.get("payload", {}) or {}).get("subscription", {}) or {} - entity = (sub_entity.get("entity", {}) or {}) + handler = WEBHOOK_HANDLERS.get(event_type) + user_id = await handler(db, payload) if handler else None - rp_sub_id = entity.get("id") or sub_entity.get("id") - status_val = entity.get("status") or sub_entity.get("status") - notes = entity.get("notes", {}) or {} + await record_payment_event(db, event_id, event_type, payload, user_id) - user_id = notes.get("user_id") - - if user_id and rp_sub_id: - import uuid - sub_result = await db.execute( - select(Subscription).where(Subscription.razorpay_subscription_id == rp_sub_id) - ) - sub = sub_result.scalar_one_or_none() - plan_from_notes = notes.get("plan_id", "pro") - if sub: - sub.subscription_status = status_val - if sub.plan == "free": - sub.plan = plan_from_notes - sub.plan_id = plan_from_notes - else: - sub = Subscription( - user_id=uuid.UUID(user_id), - plan=plan_from_notes, - plan_id=plan_from_notes, - razorpay_subscription_id=rp_sub_id, - subscription_status=status_val, - ) - db.add(sub) - - event = PaymentEvent( - provider="razorpay", - event_id=event_id, - event_type=event_type, - workspace_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), - payload=payload, - ) - db.add(event) - await db.flush() - - return {"ok": True} + return {"ok": True, "handled": handler is not None} diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..e6821fb --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel +from typing import Optional +from uuid import UUID +from datetime import datetime + + +class PlanDistributionOut(BaseModel): + plan_id: str + plan_name: str + count: int + + +class AdminOverviewOut(BaseModel): + total_users: int + active_subscribers: int + cancelled_subscribers: int + suspended_subscribers: int + free_users: int + plan_distribution: list[PlanDistributionOut] + mrr_usd: float + + +class RevenuePointOut(BaseModel): + date: str + amount_usd: float + event_count: int + + +class AdminSubscriptionOut(BaseModel): + id: UUID + user_id: UUID + email: str + full_name: Optional[str] = None + plan: str + subscription_status: Optional[str] = None + billing_cycle: Optional[str] = None + current_period_end: Optional[datetime] = None + cancel_at_period_end: bool + suspended: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class AdminUsageRowOut(BaseModel): + user_id: UUID + email: str + plan: str + job_tracks_used: int + cover_letters_used: int + period_start: str diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 45b4849..934e1a9 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -55,11 +55,18 @@ class BuilderSeedRequest(BaseModel): job_id: str -class BuilderContentUpdate(BaseModel): +class BuilderSaveRequest(BaseModel): content: dict - builder_resume_id: str -class BuilderAction(BaseModel): - builder_resume_id: str - action: str # analyze_job_match, analyze_score, generate_suggestions +class BuilderCore(BaseModel): + content: dict + latex_source: Optional[str] = None + + +class BuilderOut(BaseModel): + content: dict + latex_source: Optional[str] = None + job_match: Optional[dict] = None + score: Optional[dict] = None + suggestions: Optional[dict] = None diff --git a/backend/app/schemas/billing.py b/backend/app/schemas/billing.py index 5e6d898..9d293d2 100644 --- a/backend/app/schemas/billing.py +++ b/backend/app/schemas/billing.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, Any from uuid import UUID from datetime import datetime @@ -11,6 +11,7 @@ class BillingStatusOut(BaseModel): trial_limit: int has_pro: bool current_period_end: Optional[datetime] = None + cancel_at_period_end: bool = False class CreateSubscriptionRequest(BaseModel): @@ -20,6 +21,10 @@ class CreateSubscriptionRequest(BaseModel): trial: bool = False +class CancelSubscriptionRequest(BaseModel): + at_period_end: bool = True + + class SubscriptionOut(BaseModel): id: UUID user_id: UUID @@ -27,10 +32,25 @@ class SubscriptionOut(BaseModel): subscription_status: Optional[str] = None razorpay_subscription_id: Optional[str] = None billing_cycle: Optional[str] = None + current_period_start: Optional[datetime] = None current_period_end: Optional[datetime] = None + cancel_at_period_end: bool = False trial_ends_at: Optional[datetime] = None suspended: bool + last_payment_status: Optional[str] = None + last_payment_at: Optional[datetime] = None + paused_at: Optional[datetime] = None + cancelled_at: Optional[datetime] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class BillingHistoryItemOut(BaseModel): + id: UUID + event_type: str created_at: datetime + summary: str model_config = {"from_attributes": True} diff --git a/backend/app/schemas/usage.py b/backend/app/schemas/usage.py new file mode 100644 index 0000000..c7eebb8 --- /dev/null +++ b/backend/app/schemas/usage.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from datetime import date +from typing import Literal, Optional + + +class UsageMetricOut(BaseModel): + used: int + limit: Optional[int] = None + remaining: Optional[int] = None + unlimited: bool = False + + +class UsageOut(BaseModel): + plan: str + period_start: date + period_end: date + job_tracks: UsageMetricOut + cover_letters: UsageMetricOut + + +class PlanLimitErrorOut(BaseModel): + success: bool = False + reason: Literal["PLAN_LIMIT_REACHED"] = "PLAN_LIMIT_REACHED" + message: str + currentPlan: str + limit: Optional[int] = None + used: int diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py new file mode 100644 index 0000000..79f8e3c --- /dev/null +++ b/backend/app/services/admin.py @@ -0,0 +1,142 @@ +import uuid +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.plan import Plan +from app.models.subscription import PaymentEvent, Subscription +from app.models.usage import UserUsage +from app.models.user import User + + +async def get_overview(db: AsyncSession) -> dict: + total_users = (await db.execute(select(func.count(User.id)))).scalar_one() + + active = ( + await db.execute( + select(func.count(Subscription.id)).where( + Subscription.subscription_status == "active", Subscription.suspended.is_(False) + ) + ) + ).scalar_one() + cancelled = ( + await db.execute(select(func.count(Subscription.id)).where(Subscription.subscription_status == "cancelled")) + ).scalar_one() + suspended = (await db.execute(select(func.count(Subscription.id)).where(Subscription.suspended.is_(True)))).scalar_one() + + dist_result = await db.execute( + select(Subscription.plan_id, Plan.name, func.count(Subscription.id)) + .join(Plan, Plan.id == Subscription.plan_id, isouter=True) + .group_by(Subscription.plan_id, Plan.name) + ) + plan_distribution = [ + {"plan_id": plan_id or "free", "plan_name": plan_name or "Free", "count": count} + for plan_id, plan_name, count in dist_result.all() + ] + free_users = next((row["count"] for row in plan_distribution if row["plan_id"] == "free"), 0) + + # MRR: active, non-suspended paid subscriptions normalized to a monthly figure. + mrr_result = await db.execute( + select(Subscription.billing_cycle, Plan.monthly_price_usd, Plan.annual_price_usd) + .join(Plan, Plan.id == Subscription.plan_id) + .where( + Subscription.subscription_status == "active", + Subscription.suspended.is_(False), + Subscription.plan_id != "free", + ) + ) + mrr = 0.0 + for billing_cycle, monthly_price, annual_price in mrr_result.all(): + if billing_cycle == "annual" and annual_price: + mrr += float(annual_price) / 12 + elif monthly_price: + mrr += float(monthly_price) + + return { + "total_users": total_users, + "active_subscribers": active, + "cancelled_subscribers": cancelled, + "suspended_subscribers": suspended, + "free_users": free_users, + "plan_distribution": plan_distribution, + "mrr_usd": round(mrr, 2), + } + + +async def get_revenue_series(db: AsyncSession, days: int = 30) -> list[dict]: + since = datetime.now(timezone.utc) - timedelta(days=days) + result = await db.execute( + select(PaymentEvent) + .where(PaymentEvent.event_type.in_(["subscription.charged", "payment.authorized"])) + .where(PaymentEvent.created_at >= since) + .order_by(PaymentEvent.created_at) + ) + events = list(result.scalars().all()) + + buckets: dict[str, dict] = {} + for e in events: + day = e.created_at.date().isoformat() + entity = ((e.payload.get("payload", {}) or {}).get("payment", {}) or {}).get("entity", {}) or {} + amount = (entity.get("amount") or 0) / 100 # Razorpay amounts are in the smallest currency unit + bucket = buckets.setdefault(day, {"amount_usd": 0.0, "event_count": 0}) + bucket["amount_usd"] += amount + bucket["event_count"] += 1 + + return [ + {"date": day, "amount_usd": round(v["amount_usd"], 2), "event_count": v["event_count"]} + for day, v in sorted(buckets.items()) + ] + + +async def list_subscriptions(db: AsyncSession, limit: int = 50, offset: int = 0) -> list[dict]: + result = await db.execute( + select(Subscription, User) + .join(User, User.id == Subscription.user_id) + .order_by(Subscription.created_at.desc()) + .limit(limit) + .offset(offset) + ) + rows = [] + for sub, user in result.all(): + rows.append( + { + "id": sub.id, + "user_id": sub.user_id, + "email": user.email, + "full_name": user.full_name, + "plan": sub.plan_id or sub.plan or "free", + "subscription_status": sub.subscription_status, + "billing_cycle": sub.billing_cycle, + "current_period_end": sub.current_period_end, + "cancel_at_period_end": sub.cancel_at_period_end, + "suspended": sub.suspended, + "created_at": sub.created_at, + } + ) + return rows + + +async def list_usage(db: AsyncSession, limit: int = 50) -> list[dict]: + today = date.today() + result = await db.execute( + select(UserUsage, User, Subscription) + .join(User, User.id == UserUsage.user_id) + .join(Subscription, Subscription.user_id == UserUsage.user_id, isouter=True) + .where(UserUsage.period_start <= today, UserUsage.period_end > today) + .order_by((UserUsage.job_tracks_used + UserUsage.cover_letters_used).desc()) + .limit(limit) + ) + rows = [] + for usage, user, sub in result.all(): + rows.append( + { + "user_id": usage.user_id, + "email": user.email, + "plan": (sub.plan_id if sub else None) or "free", + "job_tracks_used": usage.job_tracks_used, + "cover_letters_used": usage.cover_letters_used, + "period_start": usage.period_start.isoformat(), + } + ) + return rows diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py index d7415c4..f16568d 100644 --- a/backend/app/services/ai.py +++ b/backend/app/services/ai.py @@ -288,41 +288,3 @@ async def log_ai_cost( await db.flush() -async def check_entitlement(db: AsyncSession, workspace_id: str, job_id: str | None = None) -> bool: - from app.models.workspace import Workspace - from app.models.job import JobArtifact, ArtifactKind - from sqlalchemy import select - import uuid as _uuid - - ws_result = await db.execute( - select(Workspace).where(Workspace.id == _uuid.UUID(workspace_id)) - ) - ws = ws_result.scalar_one_or_none() - if not ws: - return False - - if ws.plan == "pro": - return True - - from app.models.subscription import Subscription - - sub_result = await db.execute( - select(Subscription).where(Subscription.user_id == ws.owner_user_id) - ) - sub = sub_result.scalar_one_or_none() - if sub and sub.subscription_status == "active" and not sub.suspended: - return True - - art_result = await db.execute( - select(JobArtifact) - .where(JobArtifact.workspace_id == _uuid.UUID(workspace_id)) - .where(JobArtifact.kind.in_([ArtifactKind.TAILORED_RESUME, ArtifactKind.COVER_LETTER])) - ) - arts = list(art_result.scalars().all()) - - distinct_jobs = set(str(a.job_id) for a in arts if a.job_id) - if job_id and job_id in distinct_jobs: - return True - - limit = ws.trial_apps_limit - return len(distinct_jobs) < limit diff --git a/backend/app/services/builder.py b/backend/app/services/builder.py new file mode 100644 index 0000000..402b137 --- /dev/null +++ b/backend/app/services/builder.py @@ -0,0 +1,648 @@ +import os +import re +import structlog +import uuid +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.builder_resume import BuilderResume, BuilderResumeVersion +from app.services import resumes as resumes_service + +logger = structlog.get_logger() + +# The reference skeleton is `backend/config/resume-template.tex` (dev) or `./config/resume-template.tex` +# (Docker mount). Mirror services/ai::PROMPTS_PATHS so both environments resolve it. +TEMPLATE_PATHS = [ + Path(__file__).parent.parent.parent / "config" / "resume-template.tex", + Path(os.getcwd()) / "config" / "resume-template.tex", +] + +DEFAULT_PRIMARY = "0.0,0.65,0.60" # darkturquoise +DEFAULT_SECONDARY = "0.0,0.0,0.55" # darkblue + +# Field names that compose the resume header, in display order. +CONTACT_FIELDS = ["phone", "email", "linkedin", "github", "website", "location"] + +# Canonical section order used when content.section_order is absent. +DEFAULT_SECTION_ORDER = [ + "about", "skills", "work", "projects", "education", "certifications", "links", "volunteer", +] + + +def _read_config_template() -> str: + for path in TEMPLATE_PATHS: + if path.exists(): + return path.read_text() + return "" + + +def _hex_to_rgb_tuple(hex_value: str | None) -> str: + if not hex_value: + return DEFAULT_PRIMARY + m = hex_value.replace("#", "") + if len(m) != 6: + return DEFAULT_PRIMARY + try: + r = int(m[0:2], 16) / 255 + g = int(m[2:4], 16) / 255 + b = int(m[4:6], 16) / 255 + except ValueError: + return DEFAULT_PRIMARY + return f"{r:.2f},{g:.2f},{b:.2f}" + + +def latex_escape(s: str) -> str: + """Escape LaTeX-unsafe characters in free text. Leave macro scaffolding untouched.""" + if not s: + return "" + # Order matters: escape backslash first so we don't re-escape generated macros. + s = s.replace("\\", "\\textbackslash{}") + for ch, rep in [ + ("&", "\\&"), ("%", "\\%"), ("$", "\\$"), ("#", "\\#"), ("_", "\\_"), + ("{", "\\{"), ("}", "\\}"), ("~", "\\textasciitilde{}"), ("^", "\\textasciicircum{}"), + ]: + s = s.replace(ch, rep) + return s + + +def _strip_inline(s: str) -> str: + """Collapse LaTeX inline decoration to plain text for parsing.""" + s = re.sub(r"\\textbf\{", "", s) + s = re.sub(r"\\emph\{", "", s) + s = re.sub(r"\\textit\{", "", s) + s = re.sub(r"\\href\{[^}]*\}\{", "", s) + s = re.sub(r"\\color\{[^}]*\}", "", s) + s = re.sub(r"\\scshape", "", s) + s = s.replace("$|$", "|") + # Drop balanced braces that wrapped the last replacement target. + s = s.replace("{", "").replace("}", "") + s = re.sub(r"\\[a-zA-Z@]*", "", s) + s = s.replace("\\", "") + return re.sub(r"\s+", " ", s).strip() + + +def render_latex_from_content(content: dict, primary_color: str, secondary_color: str) -> str: + """Deterministic LaTeX emitter from structured content, using the JobForge skeleton macros.""" + template = _read_config_template() + primary = primary_color or DEFAULT_PRIMARY + secondary = secondary_color or DEFAULT_SECONDARY + + # Build preamble + document open: lines from \documentclass through \begin{document}, + # with interpolated colors. The body below supplies \end{document}. + if template: + start = template.find("\\documentclass") + end = template.find("\\begin{document}") + if start != -1 and end != -1: + preamble = template[start : end + len("\\begin{document}")] + else: + preamble = "" + preamble = preamble.replace("0.0, 0.65, 0.60", primary).replace("0.0, 0.0, 0.55", secondary) + else: + preamble = make_fallback_preamble(primary, secondary) + + body = [ + "\\begin{center}", + f" \\textbf{{\\Huge \\scshape \\color{{darkturquoise}} {latex_escape(content.get('contact', {}).get('name') or 'Your Name')}}} \\\\ \\vspace{{3pt}}", + " \\small", + ] + header_parts = _render_header_parts(content.get("contact") or {}) + if header_parts: + body.append(" " + " $|$\n ".join(header_parts)) + body.append("\\end{center}") + + order = content.get("section_order") or DEFAULT_SECTION_ORDER + for section in order: + rendered = _render_section(section, content) + if rendered: + body.append(rendered) + + body.append("\\end{document}") + return preamble + "\n\n" + "\n\n".join(body) + + +def make_fallback_preamble(primary: str, secondary: str) -> str: + # Minimal preamble usable when the skeleton file is unavailable. + return ( + "\\documentclass[letterpaper,11pt]{article}\n" + "\\usepackage[empty]{fullpage}\n" + "\\usepackage{titlesec}\n" + "\\usepackage[usenames,dvipsnames]{color}\n" + "\\usepackage{enumitem}\n" + "\\usepackage[hidelinks]{hyperref}\n" + "\\usepackage{fontawesome}\n" + "\\usepackage{tabularx}\n" + "\\input{glyphtounicode}\n" + f"\\definecolor{{darkturquoise}}{{rgb}}{{{primary}}}\n" + f"\\definecolor{{darkblue}}{{rgb}}{{{secondary}}}\n" + "\\titleformat{\\section}{\\vspace{-4pt}\\scshape\\raggedright\\large\\bfseries\\color{darkturquoise}}{}{0em}{}[\\color{darkturquoise}\\titlerule \\vspace{-5pt}]\n" + "\\newcommand{\\resumeItem}[1]{\\item\\small{{#1 \\vspace{-2pt}}}}\n" + "\\newcommand{\\resumeSubheading}[4]{\\vspace{-2pt}\\item\\begin{tabular*}{0.97\\textwidth}[t]{l@{\\extracolsep{\\fill}}r}\\textbf{\\color{darkblue}#1} & #2 \\\\ \\textit{\\small#3} & \\textit{\\small #4} \\\\ \\end{tabular*}\\vspace{-7pt}}\n" + "\\newcommand{\\resumeProjectHeading}[2]{\\item\\begin{tabular*}{0.97\\textwidth}{l@{\\extracolsep{\\fill}}r}\\small\\color{darkblue}#1 & #2 \\\\ \\end{tabular*}\\vspace{-7pt}}\n" + "\\newcommand{\\resumeSubHeadingListStart}{\\begin{itemize}[leftmargin=0.15in, label={}]}\n" + "\\newcommand{\\resumeSubHeadingListEnd}{\\end{itemize}}\n" + "\\newcommand{\\resumeItemListStart}{\\begin{itemize}}\n" + "\\newcommand{\\resumeItemListEnd}{\\end{itemize}\\vspace{-5pt}}\n" + "\\begin{document}\n" + ) + + +def _render_header_parts(contact: dict) -> list[str]: + parts = [] + if contact.get("phone"): + phone = latex_escape(contact["phone"]) + parts.append(f"\\faMobile \\hspace{{.5pt}} \\href{{tel:{phone}}}{{{phone}}}") + if contact.get("email"): + email = latex_escape(contact["email"]) + parts.append(f"\\faAt \\hspace{{.5pt}} \\href{{mailto:{email}}}{{{email}}}") + if contact.get("linkedin"): + url = latex_escape(contact["linkedin"]) + parts.append(f"\\faLinkedinSquare \\hspace{{.5pt}} \\href{{{url}}}{{LinkedIn}}") + if contact.get("github"): + url = latex_escape(contact["github"]) + parts.append(f"\\faGithub \\hspace{{.5pt}} \\href{{{url}}}{{GitHub}}") + if contact.get("website"): + url = latex_escape(contact["website"]) + parts.append(f"\\faGlobe \\hspace{{.5pt}} \\href{{{url}}}{{Portfolio}}") + if contact.get("location"): + parts.append(f"\\faMapMarker \\hspace{{.5pt}} {{ {latex_escape(contact['location'])} }}") + return parts + + +def _render_section(section: str, content: dict) -> str | None: + if section == "about": + about = (content.get("about") or "").strip() + if not about: + return None + return f"\\section{{Professional Summary}}\n\\noindent {latex_escape(about)}" + + if section == "skills": + groups = content.get("skills") or [] + if not groups: + return None + items = [] + for g in groups: + label = g.get("group") or "" + vals = [latex_escape(x) for x in (g.get("items") or []) if x] + if not label and not vals: + continue + items.append(f"\\textbf{{\\color{{darkblue}}{latex_escape(label)}}}{{: {', '.join(vals)}}} \\\\") + if not items: + return None + return ( + "\\section{Technical Skills}\n" + "\\begin{itemize}[leftmargin=0.15in, label={}]\n" + " \\small{\\item{\n" + "\n".join(" " + i for i in items) + "\n }}\n" + "\\end{itemize}" + ) + + if section == "work": + entries = content.get("work") or [] + if not entries: + return None + out = ["\\section{Experience}", " \\resumeSubHeadingListStart"] + for e in entries: + title = latex_escape(e.get("title") or "") + company = latex_escape(e.get("company") or "") + location = latex_escape(e.get("location") or "") + dates = f"{e.get('start') or ''} -- {e.get('end') or ''}".strip(" -") + out.append(" \\resumeSubheading") + out.append(f" {{{title}}}{{{dates}}}") + out.append(f" {{{company}}}{{{location}}}") + bullets = e.get("bullets") or [] + if bullets: + out.append(" \\resumeItemListStart") + for b in bullets: + if b: + out.append(f" \\resumeItem{{{latex_escape(b)}}}") + out.append(" \\resumeItemListEnd") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + if section == "projects": + entries = content.get("projects") or [] + if not entries: + return None + out = ["\\section{Projects}", " \\resumeSubHeadingListStart"] + for p in entries: + name = latex_escape(p.get("name") or "") + detail = latex_escape(p.get("link") or p.get("date") or "") + out.append(" \\resumeProjectHeading") + out.append(f" {{\\textbf{{\\color{{darkblue}}{name}}}}}{{{detail}}}") + bullets = p.get("bullets") or [] + if bullets: + out.append(" \\resumeItemListStart") + for b in bullets: + if b: + out.append(f" \\resumeItem{{{latex_escape(b)}}}") + out.append(" \\resumeItemListEnd") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + if section == "education": + entries = content.get("education") or [] + if not entries: + return None + out = ["\\section{Education}", " \\resumeSubHeadingListStart"] + for e in entries: + degree = latex_escape(e.get("degree") or "") + field = latex_escape(e.get("field") or "") + school = latex_escape(e.get("school") or "") + location = latex_escape(e.get("where") or e.get("location") or "") + left = f"{degree} | {field}".strip(" |") + dates = f"{e.get('start') or ''} -- {e.get('end') or ''}".strip(" -") + out.append(" \\resumeSubheading") + out.append(f" {{{left}}}{{{dates}}}") + out.append(f" {{{school}}}{{{location}}}") + details = e.get("details") or [] + if details: + out.append(" \\resumeItemListStart") + for d in details: + out.append(f" \\resumeItem{{{latex_escape(d)}}}") + out.append(" \\resumeItemListEnd") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + if section == "certifications": + entries = content.get("certifications") or [] + if not entries: + return None + out = ["\\section{Certifications}", " \\resumeSubHeadingListStart"] + for c in entries: + issuer = latex_escape(c.get("issuer") or "") + name = latex_escape(c.get("name") or "") + out.append(f" \\resumeItem{{\\textbf{{\\color{{darkblue}}{issuer}}} $|$ {name}}}") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + if section == "links": + entries = content.get("links") or [] + if not entries: + return None + out = ["\\section{Links}", " \\resumeSubHeadingListStart"] + for l in entries: + label = latex_escape(l.get("label") or "") + url = latex_escape(l.get("url") or "") + out.append(f" \\resumeItem{{\\textbf{{\\color{{darkblue}}{label}}} $|$ {url}}}") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + if section == "volunteer": + entries = content.get("volunteer") or [] + if not entries: + return None + out = ["\\section{Volunteer}", " \\resumeSubHeadingListStart"] + for v in entries: + role = latex_escape(v.get("role") or "") + org = latex_escape(v.get("org") or "") + location = latex_escape(v.get("location") or "") + dates = f"{v.get('start') or ''} -- {v.get('end') or ''}".strip(" -") + out.append(" \\resumeSubheading") + out.append(f" {{{role}}}{{{dates}}}") + out.append(f" {{{org}}}{{{location}}}") + bullets = v.get("bullets") or [] + if bullets: + out.append(" \\resumeItemListStart") + for b in bullets: + if b: + out.append(f" \\resumeItem{{{latex_escape(b)}}}") + out.append(" \\resumeItemListEnd") + out.append(" \\resumeSubHeadingListEnd") + return "\n".join(out) + + return None + + +def latex_to_content(latex: str) -> tuple[dict, bool]: + """Best-effort deterministic parse of the standard JobForge skeleton into BuilderContent.""" + content: dict[str, Any] = {} + ok = False + + if not latex: + return content, ok + + # Header name + m = re.search(r"\\textbf\{\\Huge \s*\\scshape \s*\\color\{darkturquoise\}\s*([^}]+)\}", latex) + contact: dict[str, Any] = {} + if m: + contact["name"] = _strip_inline(m.group(1)) + ok = True + contact.update(_parse_header_contacts(latex)) + if contact: + content["contact"] = contact + + section_titles = { + "Professional Summary": "about", + "Experience": "work", + "Projects": "projects", + "Education": "education", + "Technical Skills": "skills", + "Certifications": "certifications", + "Links": "links", + "Volunteer": "volunteer", + } + + # Find section blocks (allow \section* too). + hits = list(re.finditer(r"\\section\*?\s*\{([^}]+)\}", latex)) + for i, hit in enumerate(hits): + title = _strip_inline(hit.group(1)) + key = section_titles.get(title) + if not key: + continue + start = hit.end() + end = hits[i + 1].start() if i + 1 < len(hits) else latex.find("\\end{document}") + if end == -1: + end = len(latex) + block = latex[start:end] + + if key == "about": + content["about"] = _strip_inline(block.replace("\\noindent", "")) + elif key == "skills": + content["skills"] = _parse_skills(block) + elif key in ("work", "education", "projects", "volunteer"): + content[key] = _parse_entries(block, kind=key) + elif key in ("certifications", "links"): + content[key] = _parse_cert_links(block) + + if "work" in content or "education" in content: + ok = True + + order = [] + for key in DEFAULT_SECTION_ORDER: + if content.get(key): + order.append(key) + if order: + content["section_order"] = order + + if not content: + # Nothing parsed at all — not the standard skeleton. + return content, False + return content, ok + + +def _parse_header_contacts(latex: str) -> dict: + contact: dict[str, Any] = {} + patterns = { + "phone": r"\\faMobile\s+.*?\\href\{tel:([^}]+)\}\{([^}]+)\}", + "email": r"\\faAt\s+.*?\\href\{mailto:([^}]+)\}\{([^}]+)\}", + "linkedin": r"\\faLinkedinSquare\s+.*?\\href\{(https?://[^}]+)\}\{LinkedIn\}", + "github": r"\\faGithub\s+.*?\\href\{(https?://[^}]+)\}\{GitHub\}", + "website": r"\\faGlobe\s+.*?\\href\{(https?://[^}]+)\}\{Portfolio\}", + "location": r"\\faMapMarker\s+.*?\{\s*([^}]+)\}", + } + for field, pat in patterns.items(): + m = re.search(pat, latex) + if m: + contact[field] = _strip_inline(m.group(1)) + return contact + + +def _parse_skills(block: str) -> list[dict]: + groups = [] + for m in re.finditer(r"\\textbf\{\\color\{darkblue\}([^}]+)\}\{:\s*([^}]*)\}", block): + items = [x.strip() for x in _strip_inline(m.group(2)).split(",") if x.strip()] + groups.append({"group": _strip_inline(m.group(1)), "items": items}) + return groups + + +def _parse_entries(block: str, *, kind: str) -> list[dict]: + """Parse a subheading section (work/education/projects/volunteer) into list entries.""" + entries = [] + # Split on each \resumeSubheading or \resumeProjectHeading. + tokens = re.split(r"(\\resumeSubheading|\\resumeProjectHeading)", block) + i = 1 + while i < len(tokens): + marker = tokens[i] + body = tokens[i + 1] if i + 1 < len(tokens) else "" + # Extract the four braced groups after the marker, possibly across newlines. + arg_groups = re.search(r"^\s*\{(.*?)\}\s*\{(.*?)\}\s*\{(.*?)\}\s*\{(.*?)\}", body, re.S) + if marker == "\\resumeSubheading" and arg_groups: + entries.append(_build_entry(kind, arg_groups.groups(), body)) + elif marker == "\\resumeProjectHeading": + pname = re.search(r"\\textbf\{\\color\{darkblue\}([^}]+)\}\s*\$[|]\$\s*\\emph\{([^}]*)\}", body, re.S) or re.search(r"\\textbf\{\\color\{darkblue\}([^}]+)\}(\s*\$[|]\$\s*\S*)?", body, re.S) + if pname: + entries.append({ + "name": _strip_inline(pname.group(1)), + "date": _strip_inline(pname.group(2) if pname.lastindex and pname.lastindex >= 2 else ""), + "bullets": _extract_bullets(body), + }) + else: + # A stray marker; consume nothing more. + pass + # Advance past markers AND the matched arg groups' shared body is re-scanned by bullet extraction + i += 2 + return entries + + +def _build_entry(kind: str, groups: tuple, body: str) -> dict: + g = [(_strip_inline(x) if x else "") for x in groups] + if kind == "work": + return { + "title": g[0], "start": g[1].split("--")[0].strip() if "--" in g[1] else "", + "end": g[1].split("--")[1].strip() if "--" in g[1] else "", + "company": g[2], "location": _strip_inline(g[3]), + "bullets": _extract_bullets(body), + } + if kind == "education": + left = g[0] + degree, _, field = left.partition("|") + return { + "school": g[2], "degree": degree.strip(), "field": field.strip(), + "start": g[1].split("--")[0].strip() if "--" in g[1] else "", + "end": g[1].split("--")[1].strip() if "--" in g[1] else "", + "where": _strip_inline(g[3]), "details": _extract_bullets(body), + } + if kind == "volunteer": + return { + "org": g[2], "role": g[0], "location": _strip_inline(g[3]), + "start": g[1].split("--")[0].strip() if "--" in g[1] else "", + "end": g[1].split("--")[1].strip() if "--" in g[1] else "", + "bullets": _extract_bullets(body), + } + return {"title": g[0], "company": g[2], "bullets": _extract_bullets(body)} + + +def _parse_cert_links(block: str) -> list[dict]: + out = [] + for m in re.finditer(r"\\resumeItem\{\\textbf\{\\color\{darkblue\}([^}]+)\}\s*\$[|]\$\s*([^}]+)\}", block): + out.append({"issuer": _strip_inline(m.group(1)), "name": _strip_inline(m.group(2))}) + return out + + +def _extract_bullets(block: str) -> list[str]: + bullets = [] + for m in re.finditer(r"\\resumeItem\{(.*?)\}", block, re.S): + bullets.append(_strip_inline(m.group(1))) + return bullets + + +async def _get_seed_template(db: AsyncSession, workspace_id: uuid.UUID) -> str: + base = await resumes_service.get_base_resume(db, workspace_id) + if base and base.latex_source: + return base.latex_source + return _read_config_template() + + +async def get_or_create_builder( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID +) -> BuilderResume: + result = await db.execute( + select(BuilderResume).where( + BuilderResume.workspace_id == workspace_id, + BuilderResume.job_id == job_id, + ).limit(1) + ) + builder = result.scalar_one_or_none() + if builder: + return builder + + src = await _get_seed_template(db, workspace_id) + content, ok = latex_to_content(src) + if not ok: + logger.warning("builder.seed_fallback", job_id=str(job_id)) + content = _default_content(src) + + base = await resumes_service.get_base_resume(db, workspace_id) + primary = _hex_to_rgb_tuple(base.primary_color if base else None) + secondary = _hex_to_rgb_tuple(base.secondary_color if base else None) + latex = render_latex_from_content(content, primary, secondary) + + builder = BuilderResume( + workspace_id=workspace_id, + job_id=job_id, + content=content, + latex_source=latex, + primary_color=primary, + secondary_color=secondary, + ) + db.add(builder) + await db.flush() + return builder + + +def _default_content(src: str) -> dict: + name = "" + m = re.search(r"\\textbf\{\\Huge \s*\\scshape \s*\\color\{darkturquoise\}\s*([^}]+)\}", src) + if m: + name = _strip_inline(m.group(1)) + return {"contact": {"name": name}} + + +async def seed_builder(db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID) -> BuilderResume: + return await get_or_create_builder(db, workspace_id, job_id) + + +async def save_builder( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID, content: dict +) -> BuilderResume: + builder = await get_or_create_builder(db, workspace_id, job_id) + # Snapshot the pre-edit content so single-level undo can restore it. + db.add(BuilderResumeVersion( + builder_resume_id=builder.id, + workspace_id=workspace_id, + content=builder.content, + note="Pre-edit snapshot", + )) + builder.content = content + builder.latex_source = render_latex_from_content(content, builder.primary_color, builder.secondary_color) + await db.flush() + return builder + + +async def undo_builder( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID +) -> tuple[dict, str | None]: + builder = await get_or_create_builder(db, workspace_id, job_id) + result = await db.execute( + select(BuilderResumeVersion) + .where(BuilderResumeVersion.builder_resume_id == builder.id) + .order_by(BuilderResumeVersion.created_at.desc()) + .limit(1) + ) + version = result.scalar_one_or_none() + if version: + builder.content = version.content + builder.latex_source = render_latex_from_content( + version.content, builder.primary_color, builder.secondary_color + ) + await db.delete(version) + await db.flush() + return builder.content, builder.latex_source + + +def _apply_patch(content: dict, patch: dict) -> dict: + path = patch.get("path", "") + op = patch.get("op", "set") + value = patch.get("value") + parts = [p for p in path.split(".") if p != ""] + if not parts: + return content + obj: Any = content + for p in parts[:-1]: + key = int(p) if p.isdigit() else p + if isinstance(obj, dict): + obj = obj.setdefault(key, {}) + else: + obj = obj[key] + last = parts[-1] + last_key = int(last) if last.isdigit() else last + if op == "append": + if isinstance(obj, dict): + obj.setdefault(last_key, []).append(value) + elif isinstance(obj, list): + obj.append(value) + elif last_key not in obj: + obj[last_key] = [value] + else: + if isinstance(obj, dict): + obj[last_key] = value + elif isinstance(obj, list) and last_key < len(obj): + obj[last_key] = value + return content + + +async def apply_suggestion( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID, suggestion_id: str +) -> BuilderResume: + builder = await get_or_create_builder(db, workspace_id, job_id) + suggestions = builder.suggestions or {} + items = suggestions.get("suggestions") or [] + target = next((s for s in items if s.get("id") == suggestion_id), None) + if not target: + raise ValueError("Suggestion not found") + _apply_patch(builder.content, target.get("patch") or {}) + db.add(BuilderResumeVersion( + builder_resume_id=builder.id, + workspace_id=workspace_id, + content=builder.content, + note="Suggestion applied", + )) + builder.latex_source = render_latex_from_content( + builder.content, builder.primary_color, builder.secondary_color + ) + suggestions["suggestions"] = [s for s in items if s.get("id") != suggestion_id] + builder.suggestions = suggestions + await db.flush() + return builder + + +async def ignore_suggestion( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID, suggestion_id: str +) -> None: + builder = await get_or_create_builder(db, workspace_id, job_id) + suggestions = builder.suggestions or {} + items = suggestions.get("suggestions") or [] + suggestions["suggestions"] = [s for s in items if s.get("id") != suggestion_id] + builder.suggestions = suggestions + await db.flush() + + +async def save_as_base( + db: AsyncSession, workspace_id: uuid.UUID, job_id: uuid.UUID +) -> None: + builder = await get_or_create_builder(db, workspace_id, job_id) + await resumes_service.create_or_update_base_resume( + db, workspace_id, latex_source=builder.latex_source + ) diff --git a/backend/app/services/subscription.py b/backend/app/services/subscription.py new file mode 100644 index 0000000..13c05d1 --- /dev/null +++ b/backend/app/services/subscription.py @@ -0,0 +1,140 @@ +import base64 +import uuid +from datetime import datetime, timezone + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.plan import GeoPricing +from app.models.subscription import PaymentEvent, Subscription + +RAZORPAY_API_BASE = "https://api.razorpay.com/v1" + + +def _auth_header() -> str: + key_id = settings.RAZORPAY_KEY_ID.strip() + key_secret = settings.RAZORPAY_KEY_SECRET.strip() + return base64.b64encode(f"{key_id}:{key_secret}".encode()).decode() + + +def _razorpay_configured() -> bool: + return bool(settings.RAZORPAY_KEY_ID.strip() and settings.RAZORPAY_KEY_SECRET.strip()) + + +async def get_subscription_for_user(db: AsyncSession, user_id: uuid.UUID) -> Subscription | None: + result = await db.execute(select(Subscription).where(Subscription.user_id == user_id)) + return result.scalar_one_or_none() + + +async def resolve_razorpay_plan_id( + db: AsyncSession, plan_id: str, billing_cycle: str, country_code: str +) -> str | None: + """Server-side lookup of the real Razorpay plan for a (plan, cycle, country) — + never trust a client-supplied Razorpay plan id or price.""" + result = await db.execute( + select(GeoPricing) + .where(GeoPricing.plan_id == plan_id) + .where(GeoPricing.country_code.in_([country_code, "DEFAULT"])) + ) + rows = list(result.scalars().all()) + chosen = next((r for r in rows if r.country_code == country_code), None) + if not chosen: + chosen = next((r for r in rows if r.country_code == "DEFAULT"), None) + if not chosen: + return None + return chosen.razorpay_plan_id_annual if billing_cycle == "annual" else chosen.razorpay_plan_id_monthly + + +async def create_or_update_subscription_from_razorpay( + db: AsyncSession, + user_id: uuid.UUID, + plan_id: str, + billing_cycle: str, + rp_subscription_id: str, + rp_status: str | None, +) -> Subscription: + existing = await get_subscription_for_user(db, user_id) + if existing: + existing.plan = plan_id + existing.plan_id = plan_id + existing.razorpay_subscription_id = rp_subscription_id + existing.subscription_status = rp_status + existing.billing_cycle = billing_cycle + existing.cancel_at_period_end = False + existing.cancelled_at = None + await db.flush() + return existing + + sub = Subscription( + user_id=user_id, + plan=plan_id, + plan_id=plan_id, + razorpay_subscription_id=rp_subscription_id, + subscription_status=rp_status, + billing_cycle=billing_cycle, + ) + db.add(sub) + await db.flush() + return sub + + +async def cancel_subscription(db: AsyncSession, user_id: uuid.UUID, at_period_end: bool = True) -> Subscription: + sub = await get_subscription_for_user(db, user_id) + if not sub or not sub.razorpay_subscription_id or sub.plan_id == "free": + raise ValueError("No active paid subscription to cancel") + + if _razorpay_configured(): + async with httpx.AsyncClient(timeout=30) as client: + res = await client.post( + f"{RAZORPAY_API_BASE}/subscriptions/{sub.razorpay_subscription_id}/cancel", + headers={"Content-Type": "application/json", "Authorization": f"Basic {_auth_header()}"}, + json={"cancel_at_cycle_end": 1 if at_period_end else 0}, + ) + if res.status_code not in (200, 201): + raise RuntimeError(f"Razorpay cancel failed: {res.text[:500]}") + + if at_period_end: + sub.cancel_at_period_end = True + else: + sub.subscription_status = "cancelled" + sub.cancelled_at = datetime.now(timezone.utc) + sub.cancel_at_period_end = False + + await db.flush() + return sub + + +async def reactivate_subscription(db: AsyncSession, user_id: uuid.UUID) -> Subscription: + sub = await get_subscription_for_user(db, user_id) + if not sub: + raise ValueError("No subscription found") + if not sub.cancel_at_period_end: + raise ValueError("Subscription is not pending cancellation") + if sub.current_period_end and sub.current_period_end <= datetime.now(timezone.utc): + raise ValueError("Billing period has already ended — start a new subscription instead") + + if _razorpay_configured() and sub.razorpay_subscription_id: + async with httpx.AsyncClient(timeout=30) as client: + res = await client.patch( + f"{RAZORPAY_API_BASE}/subscriptions/{sub.razorpay_subscription_id}", + headers={"Content-Type": "application/json", "Authorization": f"Basic {_auth_header()}"}, + json={"cancel_at_cycle_end": 0}, + ) + if res.status_code not in (200, 201): + raise RuntimeError(f"Razorpay reactivate failed: {res.text[:500]}") + + sub.cancel_at_period_end = False + await db.flush() + return sub + + +async def list_billing_history(db: AsyncSession, user_id: uuid.UUID, limit: int = 50) -> list[PaymentEvent]: + result = await db.execute( + select(PaymentEvent) + .where(PaymentEvent.user_id == user_id) + .order_by(PaymentEvent.created_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/usage.py b/backend/app/services/usage.py new file mode 100644 index 0000000..abd0a1b --- /dev/null +++ b/backend/app/services/usage.py @@ -0,0 +1,150 @@ +import uuid +from dataclasses import dataclass +from datetime import date, datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.plan import Plan +from app.models.subscription import Subscription +from app.models.usage import UserUsage + + +@dataclass +class UsageCheckResult: + allowed: bool + used: int + limit: int | None + remaining: int | None + plan_name: str + plan_id: str + + +def _is_entitled(sub: Subscription) -> bool: + """Whether a subscription currently grants its plan's access (paid tiers only). + + Cancellation doesn't revoke access immediately — Razorpay-style billing keeps + access until `current_period_end` lapses. Pausing/suspension does revoke it now. + """ + if sub.suspended: + return False + if sub.subscription_status == "paused": + return False + if sub.subscription_status in ("active", "cancelled", "authenticated"): + if sub.current_period_end is None: + return True + return sub.current_period_end > datetime.now(timezone.utc) + return False + + +async def get_subscription_for_user(db: AsyncSession, user_id: uuid.UUID) -> Subscription | None: + result = await db.execute(select(Subscription).where(Subscription.user_id == user_id)) + return result.scalar_one_or_none() + + +async def get_active_plan(db: AsyncSession, user_id: uuid.UUID, sub: Subscription | None = None) -> Plan: + if sub is None: + sub = await get_subscription_for_user(db, user_id) + + plan_id = "free" + if sub and sub.plan_id and sub.plan_id != "free" and _is_entitled(sub): + plan_id = sub.plan_id + + result = await db.execute(select(Plan).where(Plan.id == plan_id)) + plan = result.scalar_one_or_none() + if not plan: + result = await db.execute(select(Plan).where(Plan.id == "free")) + plan = result.scalar_one_or_none() + if not plan: + raise RuntimeError("No 'free' plan row found — check plans table seed data") + return plan + + +def get_current_period(sub: Subscription | None) -> tuple[date, date]: + """Paid, entitled subscriptions use their own Razorpay billing cycle; everyone + else (free plan, lapsed/inactive subscriptions) uses the calendar month.""" + if sub and sub.current_period_start and sub.current_period_end and _is_entitled(sub): + return sub.current_period_start.date(), sub.current_period_end.date() + + now = datetime.now(timezone.utc) + start = date(now.year, now.month, 1) + end = date(now.year + 1, 1, 1) if now.month == 12 else date(now.year, now.month + 1, 1) + return start, end + + +async def get_or_create_usage( + db: AsyncSession, user_id: uuid.UUID, period_start: date, period_end: date +) -> UserUsage: + result = await db.execute( + select(UserUsage).where(UserUsage.user_id == user_id, UserUsage.period_start == period_start) + ) + usage = result.scalar_one_or_none() + if usage: + return usage + + usage = UserUsage(user_id=user_id, period_start=period_start, period_end=period_end) + db.add(usage) + await db.flush() + return usage + + +async def _check(db: AsyncSession, user_id: uuid.UUID, metric: str) -> UsageCheckResult: + sub = await get_subscription_for_user(db, user_id) + plan = await get_active_plan(db, user_id, sub=sub) + limit = plan.job_track_limit if metric == "job_tracks" else plan.cover_letter_limit + + if limit is None: + return UsageCheckResult(allowed=True, used=0, limit=None, remaining=None, plan_name=plan.name, plan_id=plan.id) + + period_start, period_end = get_current_period(sub) + usage = await get_or_create_usage(db, user_id, period_start, period_end) + used = usage.job_tracks_used if metric == "job_tracks" else usage.cover_letters_used + remaining = max(0, limit - used) + return UsageCheckResult( + allowed=used < limit, used=used, limit=limit, remaining=remaining, plan_name=plan.name, plan_id=plan.id + ) + + +async def can_create_job_track(db: AsyncSession, user_id: uuid.UUID) -> UsageCheckResult: + return await _check(db, user_id, "job_tracks") + + +async def can_generate_cover_letter(db: AsyncSession, user_id: uuid.UUID) -> UsageCheckResult: + return await _check(db, user_id, "cover_letters") + + +async def _increment(db: AsyncSession, user_id: uuid.UUID, metric: str) -> None: + sub = await get_subscription_for_user(db, user_id) + period_start, period_end = get_current_period(sub) + usage = await get_or_create_usage(db, user_id, period_start, period_end) + if metric == "job_tracks": + usage.job_tracks_used += 1 + else: + usage.cover_letters_used += 1 + await db.flush() + + +async def increment_job_track_usage(db: AsyncSession, user_id: uuid.UUID) -> None: + await _increment(db, user_id, "job_tracks") + + +async def increment_cover_letter_usage(db: AsyncSession, user_id: uuid.UUID) -> None: + await _increment(db, user_id, "cover_letters") + + +async def get_usage_snapshot(db: AsyncSession, user_id: uuid.UUID) -> dict: + job_tracks = await can_create_job_track(db, user_id) + cover_letters = await can_generate_cover_letter(db, user_id) + sub = await get_subscription_for_user(db, user_id) + period_start, period_end = get_current_period(sub) + + def _metric(r: UsageCheckResult) -> dict: + return {"used": r.used, "limit": r.limit, "remaining": r.remaining, "unlimited": r.limit is None} + + return { + "plan": job_tracks.plan_name, + "period_start": period_start, + "period_end": period_end, + "job_tracks": _metric(job_tracks), + "cover_letters": _metric(cover_letters), + } diff --git a/backend/app/services/webhook.py b/backend/app/services/webhook.py new file mode 100644 index 0000000..f591c26 --- /dev/null +++ b/backend/app/services/webhook.py @@ -0,0 +1,210 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.subscription import PaymentEvent, Subscription +from app.models.workspace import Workspace +from app.services import usage as usage_service + + +def _extract_entity(payload: dict, key: str) -> dict: + return ((payload.get("payload", {}) or {}).get(key, {}) or {}).get("entity", {}) or {} + + +def _resolve_user_id(entity: dict) -> uuid.UUID | None: + notes = entity.get("notes", {}) or {} + raw = notes.get("user_id") + if not raw: + return None + try: + return uuid.UUID(raw) + except ValueError: + return None + + +def _to_dt(epoch: int | None) -> datetime | None: + return datetime.fromtimestamp(epoch, tz=timezone.utc) if epoch else None + + +async def _find_subscription(db: AsyncSession, rp_sub_id: str | None) -> Subscription | None: + if not rp_sub_id: + return None + result = await db.execute(select(Subscription).where(Subscription.razorpay_subscription_id == rp_sub_id)) + return result.scalar_one_or_none() + + +async def _find_subscription_by_customer(db: AsyncSession, customer_id: str | None) -> Subscription | None: + if not customer_id: + return None + result = await db.execute(select(Subscription).where(Subscription.razorpay_customer_id == customer_id)) + return result.scalar_one_or_none() + + +async def _get_or_create_subscription( + db: AsyncSession, user_id: uuid.UUID | None, rp_sub_id: str | None, plan_from_notes: str +) -> Subscription | None: + sub = await _find_subscription(db, rp_sub_id) + if sub or not rp_sub_id or not user_id: + return sub + sub = Subscription(user_id=user_id, plan=plan_from_notes, plan_id=plan_from_notes, razorpay_subscription_id=rp_sub_id) + db.add(sub) + await db.flush() + return sub + + +# --- subscription.* handlers ------------------------------------------------- + +async def handle_subscription_activated(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + notes = entity.get("notes", {}) or {} + sub = await _get_or_create_subscription(db, user_id, entity.get("id"), notes.get("plan_id", "pro")) + if sub: + sub.subscription_status = "active" + sub.current_period_start = _to_dt(entity.get("current_start")) or sub.current_period_start + sub.current_period_end = _to_dt(entity.get("current_end")) or sub.current_period_end + if entity.get("customer_id"): + sub.razorpay_customer_id = entity["customer_id"] + sub.cancel_at_period_end = False + sub.cancelled_at = None + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +async def handle_subscription_charged(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + notes = entity.get("notes", {}) or {} + sub = await _get_or_create_subscription(db, user_id, entity.get("id"), notes.get("plan_id", "pro")) + if sub: + sub.subscription_status = "active" + sub.last_payment_status = "authorized" + sub.last_payment_at = datetime.now(timezone.utc) + sub.current_period_start = _to_dt(entity.get("current_start")) or sub.current_period_start + sub.current_period_end = _to_dt(entity.get("current_end")) or sub.current_period_end + await db.flush() + # Pre-create the new period's usage row so dashboards don't show a transient + # empty state right at rollover. + if sub.current_period_start and sub.current_period_end: + await usage_service.get_or_create_usage( + db, sub.user_id, sub.current_period_start.date(), sub.current_period_end.date() + ) + user_id = user_id or sub.user_id + return user_id + + +async def handle_subscription_completed(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + sub = await _find_subscription(db, entity.get("id")) + if sub: + sub.subscription_status = "completed" + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +async def handle_subscription_cancelled(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + sub = await _find_subscription(db, entity.get("id")) + if sub: + # Access continues until current_period_end naturally lapses — no forced downgrade here. + sub.subscription_status = "cancelled" + sub.cancelled_at = datetime.now(timezone.utc) + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +async def handle_subscription_paused(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + sub = await _find_subscription(db, entity.get("id")) + if sub: + sub.subscription_status = "paused" + sub.paused_at = datetime.now(timezone.utc) + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +async def handle_subscription_resumed(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "subscription") + user_id = _resolve_user_id(entity) + sub = await _find_subscription(db, entity.get("id")) + if sub: + sub.subscription_status = "active" + sub.paused_at = None + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +# --- payment.* handlers (informational — status transitions are driven by the +# subscription.* events above, not by bare payment events) ------------------- + +async def handle_payment_authorized(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "payment") + user_id = _resolve_user_id(entity) + sub = await usage_service.get_subscription_for_user(db, user_id) if user_id else None + if not sub: + sub = await _find_subscription_by_customer(db, entity.get("customer_id")) + if sub: + sub.last_payment_status = "authorized" + sub.last_payment_at = datetime.now(timezone.utc) + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +async def handle_payment_failed(db: AsyncSession, payload: dict) -> uuid.UUID | None: + entity = _extract_entity(payload, "payment") + user_id = _resolve_user_id(entity) + sub = await usage_service.get_subscription_for_user(db, user_id) if user_id else None + if not sub: + sub = await _find_subscription_by_customer(db, entity.get("customer_id")) + if sub: + sub.last_payment_status = "failed" + sub.last_payment_at = datetime.now(timezone.utc) + await db.flush() + user_id = user_id or sub.user_id + return user_id + + +WEBHOOK_HANDLERS = { + "subscription.activated": handle_subscription_activated, + "subscription.charged": handle_subscription_charged, + "subscription.completed": handle_subscription_completed, + "subscription.cancelled": handle_subscription_cancelled, + "subscription.paused": handle_subscription_paused, + "subscription.resumed": handle_subscription_resumed, + "payment.authorized": handle_payment_authorized, + "payment.failed": handle_payment_failed, +} + + +async def record_payment_event( + db: AsyncSession, event_id: str, event_type: str, payload: dict, user_id: uuid.UUID | None +) -> PaymentEvent: + workspace_id = None + if user_id: + result = await db.execute(select(Workspace).where(Workspace.owner_user_id == user_id).limit(1)) + ws = result.scalar_one_or_none() + if ws: + workspace_id = ws.id + + event = PaymentEvent( + provider="razorpay", + event_id=event_id, + event_type=event_type, + user_id=user_id, + workspace_id=workspace_id, + payload=payload, + ) + db.add(event) + await db.flush() + return event diff --git a/backend/app/tasks/usage_reset.py b/backend/app/tasks/usage_reset.py new file mode 100644 index 0000000..4e960eb --- /dev/null +++ b/backend/app/tasks/usage_reset.py @@ -0,0 +1,71 @@ +from datetime import date, datetime, timezone + +import structlog +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.celery_app import celery_app +from app.config import settings +from app.models.subscription import Subscription +from app.models.usage import UserUsage + +logger = structlog.get_logger() + + +def _calendar_month(now: datetime) -> tuple[date, date]: + start = date(now.year, now.month, 1) + end = date(now.year + 1, 1, 1) if now.month == 12 else date(now.year, now.month + 1, 1) + return start, end + + +@celery_app.task(name="reconcile_usage_periods") +def reconcile_usage_periods(): + """Daily reconciliation for the per-period `user_usage` table. + + Usage rows are created lazily on first increment, so this task isn't a + zeroing cron — it (1) pre-creates the next period's row on billing + rollover so dashboards don't show a transient empty state, and (2) flags + active paid subscriptions whose `current_period_end` has passed with no + renewal webhook received, which would otherwise silently strand a user + on stale usage limits until the next webhook retry. + """ + engine = create_engine(settings.DATABASE_URL_SYNC) + session = Session(engine) + try: + now = datetime.now(timezone.utc) + subs = session.query(Subscription).filter(Subscription.plan_id.isnot(None)).all() + + for sub in subs: + if ( + sub.plan_id + and sub.plan_id != "free" + and sub.subscription_status == "active" + and sub.current_period_end + and sub.current_period_end < now + ): + logger.warning( + "usage_reset.missed_renewal_webhook", + subscription_id=str(sub.id), + user_id=str(sub.user_id), + current_period_end=sub.current_period_end.isoformat(), + ) + continue + + if sub.current_period_start and sub.current_period_end and sub.subscription_status == "active": + period_start = sub.current_period_start.date() + period_end = sub.current_period_end.date() + else: + period_start, period_end = _calendar_month(now) + + existing = ( + session.query(UserUsage) + .filter(UserUsage.user_id == sub.user_id, UserUsage.period_start == period_start) + .first() + ) + if not existing: + session.add(UserUsage(user_id=sub.user_id, period_start=period_start, period_end=period_end)) + + session.commit() + finally: + session.close() + engine.dispose() diff --git a/backend/config/prompts/builder_score.yaml b/backend/config/prompts/builder_score.yaml index 3c427dd..76d1977 100644 --- a/backend/config/prompts/builder_score.yaml +++ b/backend/config/prompts/builder_score.yaml @@ -4,6 +4,18 @@ user_template: | Audit this resume. Return JSON: { + "section": { + "score": <0-100>, + "checks": [ + {"label": "Contact", "ok": true}, + {"label": "Summary", "ok": true}, + {"label": "Skills", "ok": true}, + {"label": "Experience", "ok": true}, + {"label": "Education", "ok": true}, + {"label": "Projects", "ok": true}, + {"label": "Certifications", "ok": true} + ] + }, "content_quality": { "score": <0-100>, "metrics": { "status": "Great"|"Ok"|"Add", "note": }, diff --git a/backend/scripts/promote_admin.py b/backend/scripts/promote_admin.py new file mode 100644 index 0000000..cb6955f --- /dev/null +++ b/backend/scripts/promote_admin.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Promote a user to admin (role='admin' in user_roles). There is no in-app UI +for this — it's the interim mechanism until/unless an admin-invite flow is built. + +Usage: + docker exec jobforgex-backend-1 python /app/scripts/promote_admin.py user@example.com + +The user must log out and log back in afterward — the JWT's `role` claim is +baked in at login time and won't reflect the DB change until a fresh token +is issued. +""" + +import os +import sys + +import psycopg2 + +DB_HOST = os.getenv("DB_HOST", "postgres") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_USER = os.getenv("DB_USER", "jobforgex") +DB_PASSWORD = os.getenv("DB_PASSWORD", "jobforgex") +DB_NAME = os.getenv("DB_NAME", "jobforgex") + + +def main(): + if len(sys.argv) != 2: + print("Usage: promote_admin.py ") + sys.exit(1) + + email = sys.argv[1].strip().lower() + conn = psycopg2.connect(host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, dbname=DB_NAME) + try: + cur = conn.cursor() + cur.execute("SELECT id FROM users WHERE lower(email) = %s", (email,)) + row = cur.fetchone() + if not row: + print(f"No user found with email {email!r}") + sys.exit(1) + user_id = row[0] + + cur.execute("SELECT id FROM user_roles WHERE user_id = %s", (user_id,)) + if cur.fetchone(): + cur.execute("UPDATE user_roles SET role = 'admin' WHERE user_id = %s", (user_id,)) + else: + cur.execute( + "INSERT INTO user_roles (id, user_id, role) VALUES (gen_random_uuid(), %s, 'admin')", (user_id,) + ) + conn.commit() + print(f"{email} is now an admin. They must log out and back in for it to take effect.") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6f83edd..a34c81e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -70,3 +70,67 @@ def test_token_response_accepts_refresh_payload(): TokenResponse.model_validate( {"access_token": "a", "refresh_token": "b", "token_type": "bearer"} ) + + +@pytest.mark.asyncio +async def test_builder_seed_without_token(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/api/v1/builder/seed", json={"job_id": "00000000-0000-0000-0000-000000000000"}) + assert response.status_code in [401, 403] + + +def test_builder_routes_in_openapi(): + """The /api/v1/builder router must be registered.""" + spec = app.openapi() + for path in ["/api/v1/builder/seed", "/api/v1/builder/{job_id}"]: + assert path in spec["paths"] + + +def test_builder_latex_roundtrip(): + """render_latex_from_content -> latex_to_content must recover the structured content.""" + from app.services.builder import render_latex_from_content, latex_to_content + + content = { + "contact": {"name": "John & Doe", "email": "john@doe.com", "phone": "+1 555 0100"}, + "about": "Builder engineer.", + "work": [ + {"company": "Acme", "title": "Sr Eng", "location": "Remote", + "start": "Jan 2021", "end": "Present", + "bullets": ["Shipped X", "Cut cost by 20%"]} + ], + "skills": [{"group": "Lang", "items": ["Python", "TypeScript"]}], + "certifications": [{"name": "SAA", "issuer": "AWS"}], + "education": [{"school": "MIT", "degree": "BSc", "field": "CS"}], + } + latex = render_latex_from_content(content, "0.0,0.65,0.60", "0.0,0.0,0.55") + assert "\\resumeSubheading" in latex + + # The emitter must open the document before any body content, or pdflatex fails + # with "Missing \\begin{document}". Regression guard for that crash. + assert "\\begin{document}" in latex + assert latex.index("\\begin{document}") < latex.index("\\begin{center}") < latex.rindex("\\end{document}") + + parsed, ok = latex_to_content(latex) + assert ok is True + assert parsed["contact"]["name"] == "John & Doe" + assert parsed["work"][0]["title"] == "Sr Eng" + assert parsed["work"][0]["bullets"] == ["Shipped X", "Cut cost by 20%"] + assert parsed["skills"][0]["items"] == ["Python", "TypeScript"] + # LaTeX-escaped chars must round-trip back to plain text + assert "&" in parsed["contact"]["name"] + + +def test_builder_apply_patch_set_and_append(): + """_apply_patch sets a scalar field or appends to a dotted-path list.""" + from app.services.builder import _apply_patch + + state = {} + _apply_patch(state, {"path": "contact.name", "op": "set", "value": "Ada"}) + assert state["contact"]["name"] == "Ada" + + items = {"work": [{"title": "Dev"}]} + _apply_patch(items, {"path": "work.0.bullets", "op": "append", "value": "Shipped thing"}) + assert items["work"][0]["bullets"] == ["Shipped thing"] + _apply_patch(items, {"path": "work.0.bullets", "op": "append", "value": "Second thing"}) + assert items["work"][0]["bullets"] == ["Shipped thing", "Second thing"] diff --git a/database/migrations/001_init.sql b/database/migrations/001_init.sql index 2e3a06c..8a3cb97 100644 --- a/database/migrations/001_init.sql +++ b/database/migrations/001_init.sql @@ -444,15 +444,57 @@ CREATE TRIGGER trg_subscriptions_updated_at -- ============================================================================ INSERT INTO plans (id, name, monthly_price_usd, annual_price_usd, job_track_limit, cover_letter_limit, features, sort_order) VALUES ('free', 'Free', 0, 0, 2, 2, '["2 job tracks", "2 cover letters", "ATS checker", "Chrome extension"]', 0), - ('pro', 'Pro', 14.00, 99.00, 30, 30, '["30 job tracks", "30 cover letters", "Structured resume builder", "ATS checker", "Chrome extension", "Priority support"]', 1), - ('unlimited', 'Unlimited', 29.00, 199.00, NULL, NULL, '["Unlimited job tracks", "Unlimited cover letters", "Structured resume builder", "ATS checker", "Chrome extension", "Priority support"]', 2) + ('pro', 'Pro', 12.00, 99.00, 30, 30, '["30 job tracks", "30 cover letters", "Structured resume builder", "ATS checker", "Chrome extension", "Priority support"]', 1), + ('unlimited', 'Unlimited', 24.00, 199.00, NULL, NULL, '["Unlimited job tracks", "Unlimited cover letters", "Structured resume builder", "ATS checker", "Chrome extension", "Priority support"]', 2) ON CONFLICT (id) DO NOTHING; INSERT INTO geo_pricing (plan_id, country_code, currency, currency_symbol, monthly_price, annual_price, priority) VALUES ('pro', 'IN', 'INR', '₹', 599.00, 3999.00, 10), - ('pro', 'US', 'USD', '$', 14.00, 99.00, 5), - ('pro', 'DEFAULT', 'USD', '$', 14.00, 99.00, 0), + ('pro', 'US', 'USD', '$', 12.00, 99.00, 5), + ('pro', 'DEFAULT', 'USD', '$', 12.00, 99.00, 0), ('unlimited', 'IN', 'INR', '₹', 1299.00, 9999.00, 10), - ('unlimited', 'US', 'USD', '$', 29.00, 249.00, 5), - ('unlimited', 'DEFAULT', 'USD', '$', 29.00, 249.00, 0) + ('unlimited', 'US', 'USD', '$', 24.00, 199.00, 5), + ('unlimited', 'DEFAULT', 'USD', '$', 24.00, 199.00, 0) ON CONFLICT DO NOTHING; + +-- Self-heal: fix price mismatches on DBs that already applied the seed above with old values +UPDATE plans SET monthly_price_usd = 12.00 WHERE id = 'pro' AND monthly_price_usd <> 12.00; +UPDATE plans SET monthly_price_usd = 24.00 WHERE id = 'unlimited' AND monthly_price_usd <> 24.00; +UPDATE geo_pricing SET monthly_price = 12.00 WHERE plan_id = 'pro' AND country_code IN ('US', 'DEFAULT') AND monthly_price <> 12.00; +UPDATE geo_pricing SET monthly_price = 24.00, annual_price = 199.00 WHERE plan_id = 'unlimited' AND country_code IN ('US', 'DEFAULT') AND (monthly_price <> 24.00 OR annual_price <> 199.00); + +-- ============================================================================ +-- USER USAGE (per billing/calendar period counters for job tracks & cover letters) +-- ============================================================================ +CREATE TABLE user_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + job_tracks_used INT NOT NULL DEFAULT 0, + cover_letters_used INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, period_start) +); +CREATE INDEX idx_user_usage_user ON user_usage (user_id); +CREATE INDEX idx_user_usage_period ON user_usage (period_start); + +CREATE TRIGGER trg_user_usage_updated_at + BEFORE UPDATE ON user_usage + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================================================ +-- PAYMENT EVENTS: attach to user (a Workspace may not exist yet at webhook time) +-- ============================================================================ +ALTER TABLE payment_events ALTER COLUMN workspace_id DROP NOT NULL; +ALTER TABLE payment_events ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_payment_events_user ON payment_events (user_id); + +-- ============================================================================ +-- SUBSCRIPTIONS: audit columns for billing history / webhook event tracking +-- ============================================================================ +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS last_payment_status TEXT; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS last_payment_at TIMESTAMPTZ; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; +ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17e2a8a..631dff7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,10 +10,13 @@ import ResumesPage from "@/pages/resumes"; import GeneratePage from "@/pages/generate"; import CheckerPage from "@/pages/checker"; import BuilderPage from "@/pages/builder"; +import BuilderIndexPage from "@/pages/builder-index"; import BillingPage from "@/pages/billing"; import SettingsPage from "@/pages/settings"; import LandingPage from "@/pages/landing"; import PrivacyPage from "@/pages/privacy"; +import AdminOverviewPage from "@/pages/admin/overview"; +import AdminSubscriptionsPage from "@/pages/admin/subscriptions"; import WhatsNewPage from "@/pages/whats-new"; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -39,6 +42,15 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { ); } +function AdminRoute({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + return ( + + {user?.role === "admin" ? children : } + + ); +} + export default function App() { return ( @@ -51,10 +63,13 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..a029317 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,61 @@ +import apiClient from "./client"; + +export interface PlanDistribution { + plan_id: string; + plan_name: string; + count: number; +} + +export interface AdminOverview { + total_users: number; + active_subscribers: number; + cancelled_subscribers: number; + suspended_subscribers: number; + free_users: number; + plan_distribution: PlanDistribution[]; + mrr_usd: number; +} + +export interface RevenuePoint { + date: string; + amount_usd: number; + event_count: number; +} + +export interface AdminSubscription { + id: string; + user_id: string; + email: string; + full_name: string | null; + plan: string; + subscription_status: string | null; + billing_cycle: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + suspended: boolean; + created_at: string; +} + +export interface AdminUsageRow { + user_id: string; + email: string; + plan: string; + job_tracks_used: number; + cover_letters_used: number; + period_start: string; +} + +export const adminApi = { + getOverview: () => apiClient.get("/api/v1/admin/stats/overview").then((r) => r.data), + + getRevenue: (days = 30) => + apiClient.get("/api/v1/admin/stats/revenue", { params: { days } }).then((r) => r.data), + + listSubscriptions: (limit = 50, offset = 0) => + apiClient + .get("/api/v1/admin/subscriptions", { params: { limit, offset } }) + .then((r) => r.data), + + listUsage: (limit = 50) => + apiClient.get("/api/v1/admin/usage", { params: { limit } }).then((r) => r.data), +}; diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts index 34cbf9e..4f44856 100644 --- a/frontend/src/api/billing.ts +++ b/frontend/src/api/billing.ts @@ -7,6 +7,7 @@ export interface BillingStatus { trial_limit: number; has_pro: boolean; current_period_end: string | null; + cancel_at_period_end: boolean; } export interface Subscription { @@ -16,9 +17,15 @@ export interface Subscription { subscription_status: string | null; razorpay_subscription_id: string | null; billing_cycle: string | null; + current_period_start: string | null; current_period_end: string | null; + cancel_at_period_end: boolean; trial_ends_at: string | null; suspended: boolean; + last_payment_status: string | null; + last_payment_at: string | null; + paused_at: string | null; + cancelled_at: string | null; } export interface Pricing { @@ -30,10 +37,35 @@ export interface Pricing { annual_price: number; } +export interface UsageMetric { + used: number; + limit: number | null; + remaining: number | null; + unlimited: boolean; +} + +export interface Usage { + plan: string; + period_start: string; + period_end: string; + job_tracks: UsageMetric; + cover_letters: UsageMetric; +} + +export interface BillingHistoryItem { + id: string; + event_type: string; + created_at: string; + summary: string; +} + export const billingApi = { getStatus: () => apiClient.get("/api/v1/billing/status").then((r) => r.data), + getUsage: () => + apiClient.get("/api/v1/billing/usage").then((r) => r.data), + createSubscription: (data: { plan_id?: string; billing_cycle?: string; @@ -41,9 +73,20 @@ export const billingApi = { trial?: boolean; }) => apiClient.post("/api/v1/billing/subscription/create", data).then((r) => r.data), + cancelSubscription: (atPeriodEnd: boolean = true) => + apiClient + .post("/api/v1/billing/subscription/cancel", { at_period_end: atPeriodEnd }) + .then((r) => r.data), + + reactivateSubscription: () => + apiClient.post("/api/v1/billing/subscription/reactivate").then((r) => r.data), + getSubscription: () => apiClient.get("/api/v1/billing/subscription").then((r) => r.data), + getBillingHistory: () => + apiClient.get("/api/v1/billing/subscription/history").then((r) => r.data), + getPricing: (countryCode?: string) => apiClient .get("/api/v1/billing/pricing", { params: { country_code: countryCode || "DEFAULT" } }) diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index e6551a2..82c679f 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -1,20 +1,25 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; -import { ListTodo, Wand2, FileText, ClipboardCheck, CreditCard, Settings, Sparkles, LogOut, Map } from "lucide-react"; +import { ListTodo, Wand2, FileText, ClipboardCheck, CreditCard, Settings, Sparkles, LogOut, ShieldCheck, Map, Layers } from "lucide-react"; import { toast } from "sonner"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, useQuery } from "@tanstack/react-query"; import logoImg from "@/assets/logo.png"; import { cn } from "@/lib/utils"; import { ThemeToggle } from "@/components/theme-toggle"; +import { Badge } from "@/components/ui/badge"; import { useAuth } from "@/context/auth-context"; import { useWorkspace } from "@/hooks/use-workspace"; +import { billingApi } from "@/api/billing"; import { useTour } from "@/components/tour"; +const PLAN_LABELS: Record = { free: "Free", pro: "Pro", unlimited: "Unlimited" }; + const NAV = [ { to: "/jobs", label: "Jobs", icon: ListTodo, tour: "nav-jobs" }, { to: "/generate", label: "Generate", icon: Wand2, tour: "nav-generate" }, { to: "/resumes", label: "Resume", icon: FileText, tour: "nav-resumes" }, { to: "/checker", label: "Checker", icon: ClipboardCheck, tour: "nav-checker" }, + { to: "/builder", label: "Resume Builder", icon: Layers, tour: "nav-builder" }, { to: "/billing", label: "Billing", icon: CreditCard, tour: "nav-billing" }, { to: "/settings", label: "Settings", icon: Settings, tour: "nav-settings" }, { to: "/whats-new", label: "What's New", icon: Sparkles, tour: "nav-whats-new" }, @@ -24,8 +29,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); const qc = useQueryClient(); - const { logout } = useAuth(); + const { logout, user } = useAuth(); const { data: ws } = useWorkspace(); + const { data: billing } = useQuery({ queryKey: ["billing"], queryFn: () => billingApi.getStatus() }); + const nav = user?.role === "admin" ? [...NAV, { to: "/admin", label: "Admin", icon: ShieldCheck, tour: "nav-admin" }] : NAV; const tour = useTour(); async function signOut() { @@ -47,7 +54,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { JobForge