diff --git a/.github/workflows/k3d-ci.yaml b/.github/workflows/k3d-ci.yaml index 30466d86a3..2a0bf58f0a 100644 --- a/.github/workflows/k3d-ci.yaml +++ b/.github/workflows/k3d-ci.yaml @@ -112,7 +112,7 @@ jobs: version: 3.10.2 - name: Create secret - run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/.github/workflows/k3d-nightly-ci.yaml b/.github/workflows/k3d-nightly-ci.yaml index 072419a4b8..041124a94c 100644 --- a/.github/workflows/k3d-nightly-ci.yaml +++ b/.github/workflows/k3d-nightly-ci.yaml @@ -93,6 +93,12 @@ jobs: with: version: 3.10.2 + - name: Create Secret + run: > + kubectl create secret generic btrix-subs-app-secret + --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} + --from-literal=BTRIX_SUBS_APP_API_KEY=TEST_PRESHARED_SECRET_PASSWORD + - name: Start Cluster with Helm run: | helm upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml -f ./chart/test/test-nightly-addons.yaml btrix ./chart/ diff --git a/.github/workflows/microk8s-ci.yaml b/.github/workflows/microk8s-ci.yaml index 33810511a8..dbd41e38f9 100644 --- a/.github/workflows/microk8s-ci.yaml +++ b/.github/workflows/microk8s-ci.yaml @@ -60,7 +60,7 @@ jobs: cache-to: type=gha,scope=frontend,mode=max - name: Create Secret - run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 60e8acd591..2ef167d6ae 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -66,7 +66,9 @@ class OA2BearerOrQuery(OAuth2PasswordBearer): """Override bearer check to also test query""" async def __call__( - self, request: Request = None, websocket: WebSocket = None # type: ignore + self, + request: Request = None, # type: ignore + websocket: WebSocket = None, # type: ignore ) -> str: param = None exc = None @@ -163,7 +165,7 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) - async def shared_secret_or_active_user( + async def shared_secret_or_superuser( token: str = Depends(oauth2_scheme), ) -> User: # allow superadmin access if token matches the known shared secret @@ -257,4 +259,4 @@ async def refresh_jwt(user=Depends(current_active_user)): user_info = await user_manager.get_user_info_with_orgs(user) return get_bearer_response(user, user_info) - return auth_jwt_router, current_active_user, shared_secret_or_active_user + return auth_jwt_router, current_active_user, shared_secret_or_superuser diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index 21a7f95bd7..0f32934dac 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -172,9 +172,7 @@ def main() -> None: user_manager = init_user_manager(mdb, email, invites) - current_active_user, shared_secret_or_active_user = init_users_api( - app, user_manager - ) + current_active_user, shared_secret_or_superuser = init_users_api(app, user_manager) org_ops = init_orgs_api( app, @@ -183,9 +181,10 @@ def main() -> None: crawl_manager, invites, current_active_user, + shared_secret_or_superuser, ) - init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_active_user) + init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_superuser) event_webhook_ops = init_event_webhooks_api(mdb, org_ops, app_root) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index e729730c2b..8fb94be195 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1857,6 +1857,8 @@ class OrgQuotasIn(BaseModel): extraExecMinutes: Optional[int] = None giftedExecMinutes: Optional[int] = None + context: str | None = None + # ============================================================================ class Plan(BaseModel): @@ -1980,6 +1982,30 @@ class SubscriptionPortalUrlResponse(BaseModel): portalUrl: str = "" +# ============================================================================ +class AddonMinutesPricing(BaseModel): + """Addon minutes pricing""" + + value: float + currency: str + + +# ============================================================================ +class CheckoutAddonMinutesRequest(BaseModel): + """Request for additional minutes checkout session""" + + orgId: str + subId: str + minutes: int | None = None + return_url: str + + +class CheckoutAddonMinutesResponse(BaseModel): + """Response for additional minutes checkout session""" + + checkoutUrl: str + + # ============================================================================ class Subscription(BaseModel): """subscription data""" @@ -2058,6 +2084,7 @@ class OrgQuotaUpdate(BaseModel): modified: datetime update: OrgQuotas + context: str | None = None # ============================================================================ diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 8acb083b73..1e148458de 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -12,8 +12,19 @@ from uuid import UUID, uuid4 from tempfile import NamedTemporaryFile -from typing import Optional, TYPE_CHECKING, Dict, Callable, List, AsyncGenerator, Any +from typing import ( + Awaitable, + Optional, + TYPE_CHECKING, + Dict, + Callable, + List, + Literal, + AsyncGenerator, + Any, +) +from motor.motor_asyncio import AsyncIOMotorDatabase from pydantic import ValidationError from pymongo import ReturnDocument from pymongo.collation import Collation @@ -547,9 +558,15 @@ async def update_subscription_data( org = Organization.from_dict(org_data) if update.quotas: - # don't change gifted minutes here + # don't change gifted or extra minutes here update.quotas.giftedExecMinutes = None - await self.update_quotas(org, update.quotas) + update.quotas.extraExecMinutes = None + await self.update_quotas( + org, + update.quotas, + mode="set", + context=f"subscription_change:{update.planId}", + ) return org @@ -600,9 +617,17 @@ async def update_proxies(self, org: Organization, proxies: OrgProxies) -> None: }, ) - async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None: + async def update_quotas( + self, + org: Organization, + quotas: OrgQuotasIn, + mode: Literal["set", "add"], + context: str | None = None, + ) -> None: """update organization quotas""" + quotas.context = None + previous_extra_mins = ( org.quotas.extraExecMinutes if (org.quotas and org.quotas.extraExecMinutes) @@ -614,51 +639,65 @@ async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None: else 0 ) - update = quotas.dict( - exclude_unset=True, exclude_defaults=True, exclude_none=True - ) + if mode == "add": + increment_update: dict[str, Any] = { + "$inc": {}, + } - quota_updates = [] - for prev_update in org.quotaUpdates or []: - quota_updates.append(prev_update.dict()) - quota_updates.append(OrgQuotaUpdate(update=update, modified=dt_now()).dict()) + for field, value in quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ).items(): + if field == "context" or value is None: + continue + inc = max(value, -org.quotas.model_dump().get(field, 0)) + increment_update["$inc"][f"quotas.{field}"] = inc - await self.orgs.find_one_and_update( - {"_id": org.id}, - { - "$set": { - "quotas": update, - "quotaUpdates": quota_updates, - } + updated_org = await self.orgs.find_one_and_update( + {"_id": org.id}, + increment_update, + projection={"quotas": True}, + return_document=ReturnDocument.AFTER, + ) + quotas = OrgQuotasIn(**updated_org["quotas"]) + + update: dict[str, dict[str, dict[str, Any] | int]] = { + "$push": { + "quotaUpdates": OrgQuotaUpdate( + modified=dt_now(), + update=OrgQuotas( + **quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ) + ), + context=context, + ).model_dump() }, - ) + "$inc": {}, + "$set": {}, + } + + if mode == "set": + increment_update = quotas.model_dump( + exclude_unset=True, exclude_defaults=True, exclude_none=True + ) + update["$set"]["quotas"] = increment_update # Inc org available fields for extra/gifted execution time as needed if quotas.extraExecMinutes is not None: extra_secs_diff = (quotas.extraExecMinutes - previous_extra_mins) * 60 if org.extraExecSecondsAvailable + extra_secs_diff <= 0: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"extraExecSecondsAvailable": 0}}, - ) + update["$set"]["extraExecSecondsAvailable"] = 0 else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"extraExecSecondsAvailable": extra_secs_diff}}, - ) + update["$inc"]["extraExecSecondsAvailable"] = extra_secs_diff if quotas.giftedExecMinutes is not None: gifted_secs_diff = (quotas.giftedExecMinutes - previous_gifted_mins) * 60 if org.giftedExecSecondsAvailable + gifted_secs_diff <= 0: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$set": {"giftedExecSecondsAvailable": 0}}, - ) + update["$set"]["giftedExecSecondsAvailable"] = 0 else: - await self.orgs.find_one_and_update( - {"_id": org.id}, - {"$inc": {"giftedExecSecondsAvailable": gifted_secs_diff}}, - ) + update["$inc"]["giftedExecSecondsAvailable"] = gifted_secs_diff + + await self.orgs.find_one_and_update({"_id": org.id}, update) async def update_event_webhook_urls( self, org: Organization, urls: OrgWebhookUrls @@ -1128,7 +1167,7 @@ async def json_items_gen( yield b"\n" doc_index += 1 - yield f']{"" if skip_closing_comma else ","}\n'.encode("utf-8") + yield f"]{'' if skip_closing_comma else ','}\n".encode("utf-8") async def json_closing_gen() -> AsyncGenerator: """Async generator to close JSON document""" @@ -1436,10 +1475,12 @@ async def delete_org_and_data( async def recalculate_storage(self, org: Organization) -> dict[str, bool]: """Recalculate org storage use""" try: - total_crawl_size, crawl_size, upload_size = ( - await self.base_crawl_ops.calculate_org_crawl_file_storage( - org.id, - ) + ( + total_crawl_size, + crawl_size, + upload_size, + ) = await self.base_crawl_ops.calculate_org_crawl_file_storage( + org.id, ) profile_size = await self.profile_ops.calculate_org_profile_file_storage( org.id @@ -1496,12 +1537,13 @@ async def inc_org_bytes_stored_field(self, oid: UUID, field: str, size: int): # ============================================================================ # pylint: disable=too-many-statements, too-many-arguments def init_orgs_api( - app, - mdb, + app: APIRouter, + mdb: AsyncIOMotorDatabase[Any], user_manager: UserManager, crawl_manager: CrawlManager, invites: InviteOps, - user_dep: Callable, + user_dep: Callable[[str], Awaitable[User]], + superuser_or_shared_secret_dep: Callable[[str], Awaitable[User]], ): """Init organizations api router for /orgs""" # pylint: disable=too-many-locals,invalid-name @@ -1520,6 +1562,20 @@ async def org_dep(oid: UUID, user: User = Depends(user_dep)): return org + async def org_superuser_or_shared_secret_dep( + oid: UUID, user: User = Depends(superuser_or_shared_secret_dep) + ): + org = await ops.get_org_for_user_by_id(oid, user) + if not org: + raise HTTPException(status_code=404, detail="org_not_found") + if not org.is_viewer(user): + raise HTTPException( + status_code=403, + detail="User does not have permission to view this organization", + ) + + return org + async def org_crawl_dep( org: Organization = Depends(org_dep), user: User = Depends(user_dep) ): @@ -1656,7 +1712,18 @@ async def update_quotas( if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") - await ops.update_quotas(org, quotas) + await ops.update_quotas(org, quotas, mode="set", context=quotas.context) + + return {"updated": True} + + @app.post( + "/orgs/{oid}/quotas/add", tags=["organizations"], response_model=UpdatedResponse + ) + async def update_quotas_add( + quotas: OrgQuotasIn, + org: Organization = Depends(org_superuser_or_shared_secret_dep), + ): + await ops.update_quotas(org, quotas, mode="add", context=quotas.context) return {"updated": True} diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 9180ae3878..a4e9c22157 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -2,19 +2,23 @@ Subscription API handling """ -from typing import Callable, Union, Any, Optional, Tuple, List +from typing import Awaitable, Callable, Union, Any, Optional, Tuple, List import os import asyncio from uuid import UUID from datetime import datetime -from fastapi import Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request import aiohttp +from motor.motor_asyncio import AsyncIOMotorDatabase from .orgs import OrgOps from .users import UserManager from .utils import is_bool, get_origin from .models import ( + AddonMinutesPricing, + CheckoutAddonMinutesRequest, + CheckoutAddonMinutesResponse, SubscriptionCreate, SubscriptionImport, SubscriptionUpdate, @@ -363,11 +367,11 @@ async def get_billing_portal_url( async with aiohttp.ClientSession() as session: async with session.request( "POST", - external_subs_app_api_url, + f"{external_subs_app_api_url}/portalUrl", headers={ "Authorization": "bearer " + external_subs_app_api_key }, - json=req.dict(), + json=req.model_dump(), raise_for_status=True, ) as resp: json = await resp.json() @@ -378,14 +382,77 @@ async def get_billing_portal_url( return SubscriptionPortalUrlResponse() + async def get_execution_minutes_price(self, org: Organization): + """Fetch price for addon execution minutes from external subscription app""" + if not org.subscription: + raise HTTPException( + status_code=404, detail="Organization has no subscription" + ) + if external_subs_app_api_url: + try: + async with aiohttp.ClientSession() as session: + async with session.request( + "GET", + f"{external_subs_app_api_url}/prices/additionalMinutes", + headers={ + "Authorization": "bearer " + external_subs_app_api_key, + }, + raise_for_status=True, + ) as resp: + json = await resp.json() + return AddonMinutesPricing(**json) + # pylint: disable=broad-exception-caught + except Exception as exc: + print("Error fetching execution minutes price", exc) + + async def get_checkout_url( + self, + org: Organization, + headers: dict[str, str], + minutes: int | None, + ): + """Create checkout url for additional minutes""" + if not org.subscription: + raise HTTPException( + status_code=404, detail="Organization has no subscription" + ) + subscription_id = org.subscription.subId + return_url = f"{get_origin(headers)}/orgs/{org.slug}/settings/billing" -# pylint: disable=invalid-name,too-many-arguments + if external_subs_app_api_url: + try: + req = CheckoutAddonMinutesRequest( + orgId=str(org.id), + subId=subscription_id, + minutes=minutes, + return_url=return_url, + ) + async with aiohttp.ClientSession() as session: + async with session.request( + "POST", + f"{external_subs_app_api_url}/checkout/additionalMinutes", + headers={ + "Authorization": "bearer " + external_subs_app_api_key, + "Content-Type": "application/json", + }, + json=req.model_dump(), + raise_for_status=True, + ) as resp: + json = await resp.json() + print(f"get_checkout_url got response: {json}") + return CheckoutAddonMinutesResponse(**json) + # pylint: disable=broad-exception-caught + except Exception as exc: + print("Error fetching checkout url", exc) + + +# pylint: disable=invalid-name,too-many-arguments,too-many-locals def init_subs_api( - app, - mdb, + app: APIRouter, + mdb: AsyncIOMotorDatabase[Any], org_ops: OrgOps, user_manager: UserManager, - user_or_shared_secret_dep: Callable, + superuser_or_shared_secret_dep: Callable[[str], Awaitable[User]], ) -> Optional[SubOps]: """init subs API""" @@ -402,14 +469,14 @@ def init_subs_api( async def new_sub( create: SubscriptionCreate, request: Request, - user: User = Depends(user_or_shared_secret_dep), + user: User = Depends(superuser_or_shared_secret_dep), ): return await ops.create_new_subscription(create, user, request) @app.post( "/subscriptions/import", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=AddedResponseId, ) async def import_sub(sub_import: SubscriptionImport): @@ -418,7 +485,7 @@ async def import_sub(sub_import: SubscriptionImport): @app.post( "/subscriptions/update", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=UpdatedResponse, ) async def update_subscription( @@ -429,7 +496,7 @@ async def update_subscription( @app.post( "/subscriptions/cancel", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SubscriptionCanceledResponse, ) async def cancel_subscription( @@ -440,7 +507,7 @@ async def cancel_subscription( @app.post( "/subscriptions/send-trial-end-reminder", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SuccessResponse, ) async def send_trial_end_reminder( @@ -453,7 +520,7 @@ async def send_trial_end_reminder( @app.get( "/subscriptions/is-activated/{sub_id}", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=SuccessResponse, ) async def is_subscription_activated( @@ -465,7 +532,7 @@ async def is_subscription_activated( @app.get( "/subscriptions/events", tags=["subscriptions"], - dependencies=[Depends(user_or_shared_secret_dep)], + dependencies=[Depends(superuser_or_shared_secret_dep)], response_model=PaginatedSubscriptionEventResponse, ) async def get_sub_events( @@ -501,4 +568,26 @@ async def get_billing_portal_url( ): return await ops.get_billing_portal_url(org, dict(request.headers)) + @org_ops.router.get( + "/price/execution-minutes", + tags=["organizations"], + response_model=AddonMinutesPricing, + ) + async def get_execution_minutes_price( + org: Organization = Depends(org_ops.org_owner_dep), + ): + return await ops.get_execution_minutes_price(org) + + @org_ops.router.get( + "/checkout/execution-minutes", + tags=["organizations"], + response_model=CheckoutAddonMinutesResponse, + ) + async def get_execution_minutes_checkout_url( + request: Request, + minutes: int | None = None, + org: Organization = Depends(org_ops.org_owner_dep), + ): + return await ops.get_checkout_url(org, dict(request.headers), minutes) + return ops diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index f458238570..8ba9f07e02 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -596,7 +596,7 @@ def init_user_manager(mdb, emailsender, invites): def init_users_api(app, user_manager: UserManager): """init fastapi_users""" - auth_jwt_router, current_active_user, shared_secret_or_active_user = init_jwt_auth( + auth_jwt_router, current_active_user, shared_secret_or_superuser = init_jwt_auth( user_manager ) @@ -618,7 +618,7 @@ def init_users_api(app, user_manager: UserManager): tags=["users"], ) - return current_active_user, shared_secret_or_active_user + return current_active_user, shared_secret_or_superuser # ============================================================================ diff --git a/backend/test/echo_server.py b/backend/test/echo_server.py index 0da8715a4e..097bb2d301 100644 --- a/backend/test/echo_server.py +++ b/backend/test/echo_server.py @@ -2,6 +2,7 @@ """ A web server to record POST requests and return them on a GET request """ + from http.server import HTTPServer, BaseHTTPRequestHandler import json @@ -29,6 +30,14 @@ def do_POST(self): "utf-8" ) ) + elif self.path.endswith("/checkout/additionalMinutes"): + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps( + {"checkoutUrl": "https://checkout.example.com/path/"} + ).encode("utf-8") + ) else: self.end_headers() diff --git a/backend/test/test_org_subs.py b/backend/test/test_org_subs.py index 429a32bde9..e17c2c35ef 100644 --- a/backend/test/test_org_subs.py +++ b/backend/test/test_org_subs.py @@ -334,6 +334,16 @@ def test_get_billing_portal_url(admin_auth_headers, echo_server): assert r.json() == {"portalUrl": "https://portal.example.com/path/"} +def test_get_addon_minutes_checkout_url(admin_auth_headers, echo_server): + r = requests.get( + f"{API_PREFIX}/orgs/{new_subs_oid}/checkout/execution-minutes", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + + assert r.json() == {"checkoutUrl": "https://checkout.example.com/path/"} + + def test_cancel_sub_and_delete_org(admin_auth_headers): # cancel, resulting in org deletion r = requests.post( @@ -498,6 +508,7 @@ def test_subscription_events_log(admin_auth_headers, non_default_org_id): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -566,6 +577,7 @@ def test_subscription_events_log_filter_sub_id(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -627,6 +639,7 @@ def test_subscription_events_log_filter_oid(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -662,6 +675,7 @@ def test_subscription_events_log_filter_plan_id(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, @@ -713,6 +727,7 @@ def test_subscription_events_log_filter_status(admin_auth_headers): "planId": "basic2", "futureCancelDate": None, "quotas": { + "context": None, "maxPagesPerCrawl": 50, "storageQuota": 500000, "extraExecMinutes": None, diff --git a/backend/test_nightly/conftest.py b/backend/test_nightly/conftest.py index d984d4642c..b369774de1 100644 --- a/backend/test_nightly/conftest.py +++ b/backend/test_nightly/conftest.py @@ -13,6 +13,8 @@ CRAWLER_USERNAME = "crawlernightly@example.com" CRAWLER_PW = "crawlerPASSWORD!" +PRESHARED_SECRET_PW = "TEST_PRESHARED_SECRET_PASSWORD" + @pytest.fixture(scope="session") def admin_auth_headers(): @@ -33,6 +35,11 @@ def admin_auth_headers(): time.sleep(5) +@pytest.fixture(scope="session") +def preshared_secret_auth_headers(): + return {"Authorization": f"Bearer {PRESHARED_SECRET_PW}"} + + @pytest.fixture(scope="session") def default_org_id(admin_auth_headers): while True: diff --git a/backend/test_nightly/test_execution_minutes_quota.py b/backend/test_nightly/test_execution_minutes_quota.py index cd81d86330..992d732bae 100644 --- a/backend/test_nightly/test_execution_minutes_quota.py +++ b/backend/test_nightly/test_execution_minutes_quota.py @@ -1,20 +1,21 @@ import math -import requests import time -import pytest - from typing import Dict +import pytest +import requests + from .conftest import API_PREFIX from .utils import get_crawl_status - EXEC_MINS_QUOTA = 1 EXEC_SECS_QUOTA = EXEC_MINS_QUOTA * 60 GIFTED_MINS_QUOTA = 3 GIFTED_SECS_QUOTA = GIFTED_MINS_QUOTA * 60 EXTRA_MINS_QUOTA = 5 EXTRA_SECS_QUOTA = EXTRA_MINS_QUOTA * 60 +EXTRA_MINS_ADDED_QUOTA = 7 +EXTRA_SECS_ADDED_QUOTA = EXTRA_MINS_ADDED_QUOTA * 60 config_id = None @@ -195,3 +196,36 @@ def test_unset_execution_mins_quota(org_with_quotas, admin_auth_headers): ) data = r.json() assert data.get("updated") == True + + +def test_add_execution_mins_extra_quotas( + org_with_quotas, admin_auth_headers, preshared_secret_auth_headers +): + r = requests.post( + f"{API_PREFIX}/orgs/{org_with_quotas}/quotas/add", + headers=preshared_secret_auth_headers, + json={ + "extraExecMinutes": EXTRA_MINS_ADDED_QUOTA, + "context": "test context 123", + }, + ) + data = r.json() + assert data.get("updated") == True + + # Ensure org data looks as we expect + r = requests.get( + f"{API_PREFIX}/orgs/{org_with_quotas}", + headers=admin_auth_headers, + ) + data = r.json() + assert ( + data["extraExecSecondsAvailable"] == EXTRA_SECS_QUOTA + EXTRA_SECS_ADDED_QUOTA + ) + assert data["giftedExecSecondsAvailable"] == GIFTED_SECS_QUOTA + assert data["extraExecSeconds"] == {} + assert data["giftedExecSeconds"] == {} + assert len(data["quotaUpdates"]) + for update in data["quotaUpdates"]: + assert update["modified"] + assert update["update"] + assert data["quotaUpdates"][-1]["context"] == "test context 123" diff --git a/frontend/src/pages/org/settings/components/billing-addon-link.ts b/frontend/src/pages/org/settings/components/billing-addon-link.ts new file mode 100644 index 0000000000..4964766baa --- /dev/null +++ b/frontend/src/pages/org/settings/components/billing-addon-link.ts @@ -0,0 +1,172 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import { type SlSelectEvent } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type BillingAddonCheckout } from "@/types/billing"; +import appState from "@/utils/state"; + +const PRESET_MINUTES = [100, 600, 1500, 3000]; + +type Price = { + value: number; + currency: string; +}; + +@customElement("btrix-org-settings-billing-addon-link") +@localized() +export class OrgSettingsBillingAddonLink extends BtrixElement { + static _price: Price | undefined; + + @state() + private lastClickedMinutesPreset: number | undefined = undefined; + + private readonly price = new Task(this, { + task: async () => { + if (OrgSettingsBillingAddonLink._price) + return OrgSettingsBillingAddonLink._price; + try { + const price = await this.api.fetch( + `/orgs/${this.orgId}/price/execution-minutes`, + ); + OrgSettingsBillingAddonLink._price = price; + return price; + } catch (error) { + console.log("Failed to fetch price", error); + return; + } + }, + args: () => [] as const, + }); + + private readonly checkoutUrl = new Task(this, { + task: async ([minutes]) => { + if (!appState.settings?.billingEnabled || !appState.org?.subscription) + return; + + try { + const { checkoutUrl } = await this.getCheckoutUrl(minutes); + + if (checkoutUrl) { + return checkoutUrl; + } else { + throw new Error("Missing checkoutUrl"); + } + } catch (e) { + console.debug(e); + + throw new Error( + msg("Sorry, couldn't retrieve current plan at this time."), + ); + } + }, + args: () => [undefined] as readonly [number | undefined], + autoRun: false, + }); + private async getCheckoutUrl(minutes?: number | undefined) { + const params = new URLSearchParams(); + if (minutes) params.append("minutes", minutes.toString()); + return this.api.fetch( + `/orgs/${this.orgId}/checkout/execution-minutes?${params.toString()}`, + ); + } + + private readonly localizeMinutes = (minutes: number) => { + return this.localize.number(minutes, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }); + }; + + private async checkout(minutes?: number | undefined) { + await this.checkoutUrl.run([minutes]); + if (this.checkoutUrl.value) { + window.location.href = this.checkoutUrl.value; + } else { + this.notify.toast({ + message: msg("Sorry, checkout isn’t available at this time."), + id: "checkout-unavailable", + variant: "warning", + }); + } + } + + render() { + const priceForMinutes = (minutes: number) => { + if (!this.price.value) return; + return this.localize.number(minutes * this.price.value.value, { + style: "currency", + currency: this.price.value.currency, + }); + }; + const price = priceForMinutes(1); + return html` + { + this.lastClickedMinutesPreset = undefined; + await this.checkout(); + }} + size="small" + variant="text" + ?loading=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset === undefined} + ?disabled=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset !== undefined} + class="-ml-3" + > + ${msg("Add More Execution Minutes")} + +
+ { + this.lastClickedMinutesPreset = parseInt(e.detail.item.value); + await this.checkout(this.lastClickedMinutesPreset); + void e.detail.item.closest("sl-dropdown")!.hide(); + }} + > + + + ${msg("Preset minute amounts")} + + + + ${msg("Preset minute amounts")} +
+ ${msg("Amounts are adjustable during checkout.")} +
+
+ ${PRESET_MINUTES.map((m) => { + const minutes = this.localizeMinutes(m); + return html` + + ${minutes} + ${this.price.value && + html` + ${priceForMinutes(m)} + `} + + `; + })} +
+
+ ${this.price.value && + html`
+ ${msg(html`${price} per minute`)} +
`} + `; + } +} diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 5c8035f73c..8f273e5a0b 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -12,11 +12,10 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { columns } from "@/layouts/columns"; import { SubscriptionStatus, type BillingPortal } from "@/types/billing"; import type { OrgData, OrgQuotas } from "@/types/org"; -import { humanizeSeconds } from "@/utils/executionTimeFormatter"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; -const linkClassList = tw`transition-color text-primary hover:text-primary-500`; +const linkClassList = tw`text-primary transition-colors hover:text-primary-600`; const manageLinkClasslist = clsx( linkClassList, tw`flex cursor-pointer items-center gap-2 p-2 text-sm font-semibold leading-none`, @@ -95,7 +94,12 @@ export class OrgSettingsBilling extends BtrixElement { ${columns([ [ html` -
+
@@ -187,13 +191,31 @@ export class OrgSettingsBilling extends BtrixElement { ${when( this.org, - (org) => this.renderQuotas(org.quotas), + (org) => this.renderMonthlyQuotas(org.quotas), () => html` `, )} + ${when( + this.org?.quotas.extraExecMinutes || + this.org?.quotas.giftedExecMinutes, + () => + html`
+ ${msg("Add-ons")} +
+ ${this.renderExtraQuotas(this.org!.quotas)}`, + )} + ${when( + this.org?.subscription, + () => + html``, + )}
`, html` @@ -343,15 +365,14 @@ export class OrgSettingsBilling extends BtrixElement { : nothing}`; }; - private readonly renderQuotas = (quotas: OrgQuotas) => { + private readonly renderMonthlyQuotas = (quotas: OrgQuotas) => { const maxExecMinutesPerMonth = quotas.maxExecMinutesPerMonth && - humanizeSeconds( - quotas.maxExecMinutesPerMonth * 60, - this.localize.lang(), - undefined, - "long", - ); + this.localize.number(quotas.maxExecMinutesPerMonth, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }); const maxPagesPerCrawl = quotas.maxPagesPerCrawl && `${this.localize.number(quotas.maxPagesPerCrawl)} ${pluralOf("pages", quotas.maxPagesPerCrawl)}`; @@ -368,7 +389,7 @@ export class OrgSettingsBilling extends BtrixElement {
  • ${msg( - str`${maxExecMinutesPerMonth || msg("Unlimited minutes")} of crawling time`, + str`${maxExecMinutesPerMonth || msg("Unlimited minutes")} of execution time`, )}
  • ${msg(str`${storageBytesText} of disk space`)}
  • @@ -380,6 +401,37 @@ export class OrgSettingsBilling extends BtrixElement { `; }; + private readonly renderExtraQuotas = (quotas: OrgQuotas) => { + const extraExecMinutes = quotas.extraExecMinutes + ? this.localize.number(quotas.extraExecMinutes, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }) + : null; + + const giftedExecMinutes = quotas.giftedExecMinutes + ? this.localize.number(quotas.giftedExecMinutes, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }) + : null; + + return html` +
      + ${extraExecMinutes && + html`
    • + ${msg(str`${extraExecMinutes} of add-on execution time`)} +
    • `} + ${giftedExecMinutes && + html`
    • + ${msg(str`${giftedExecMinutes} of gifted execution time`)} +
    • `} +
    + `; + }; + private renderPortalLink() { return html` ; +export const billingAddonCheckoutSchema = z.object({ + checkoutUrl: z.string().url(), +}); +export type BillingAddonCheckout = z.infer;