diff --git a/backend/btrixcloud/emailsender.py b/backend/btrixcloud/emailsender.py index 50b1d2dc4..ab1576737 100644 --- a/backend/btrixcloud/emailsender.py +++ b/backend/btrixcloud/emailsender.py @@ -14,7 +14,7 @@ from fastapi.templating import Jinja2Templates from .models import CreateReplicaJob, DeleteReplicaJob, Organization, InvitePending -from .utils import is_bool +from .utils import is_bool, get_origin # pylint: disable=too-few-public-methods, too-many-instance-attributes @@ -33,8 +33,6 @@ class EmailSender: log_sent_emails: bool - default_origin: str - def __init__(self): self.sender = os.environ.get("EMAIL_SENDER") or "Browsertrix admin" self.password = os.environ.get("EMAIL_PASSWORD") or "" @@ -46,8 +44,6 @@ def __init__(self): self.log_sent_emails = is_bool(os.environ.get("LOG_SENT_EMAILS")) - self.default_origin = os.environ.get("APP_ORIGIN", "") - self.templates = Jinja2Templates( directory=os.path.join(os.path.dirname(__file__), "email-templates") ) @@ -101,24 +97,12 @@ def _send_encrypted(self, receiver: str, name: str, **kwargs) -> None: server.send_message(msg) # server.sendmail(self.sender, receiver, message) - def get_origin(self, headers) -> str: - """Return origin of the received request""" - if not headers: - return self.default_origin - - scheme = headers.get("X-Forwarded-Proto") - host = headers.get("Host") - if not scheme or not host: - return self.default_origin - - return scheme + "://" + host - def send_user_validation( self, receiver_email: str, token: str, headers: Optional[dict] = None ): """Send email to validate registration email address""" - origin = self.get_origin(headers) + origin = get_origin(headers) self._send_encrypted(receiver_email, "validate", origin=origin, token=token) @@ -133,7 +117,7 @@ def send_user_invite( ): """Send email to invite new user""" - origin = self.get_origin(headers) + origin = get_origin(headers) receiver_email = invite.email or "" @@ -155,7 +139,7 @@ def send_user_invite( def send_user_forgot_password(self, receiver_email, token, headers=None): """Send password reset email with token""" - origin = self.get_origin(headers) + origin = get_origin(headers) self._send_encrypted( receiver_email, diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index aef2b0ab7..b71cf6ebd 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1177,6 +1177,7 @@ class SubscriptionUpdate(BaseModel): planId: str futureCancelDate: Optional[datetime] = None + quotas: Optional[OrgQuotas] = None # ============================================================================ @@ -1204,9 +1205,14 @@ class SubscriptionCancelOut(SubscriptionCancel, SubscriptionEventOut): class SubscriptionPortalUrlRequest(BaseModel): """Request for subscription update pull""" + returnUrl: str + subId: str planId: str + bytesStored: int + execSeconds: int + # ============================================================================ class SubscriptionPortalUrlResponse(BaseModel): diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 07aa71afd..45b7e1577 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -484,7 +484,14 @@ async def update_subscription_data( {"$set": query}, return_document=ReturnDocument.AFTER, ) - return Organization.from_dict(org_data) if org_data else None + if not org_data: + return None + + org = Organization.from_dict(org_data) + if update.quotas: + await self.update_quotas(org, update.quotas) + + return org async def cancel_subscription_data( self, cancel: SubscriptionCancel diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index b87a60f3a..5cb4d779e 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -11,7 +11,7 @@ from .orgs import OrgOps from .users import UserManager -from .utils import is_bool +from .utils import is_bool, get_origin from .models import ( SubscriptionCreate, SubscriptionImport, @@ -264,16 +264,22 @@ async def list_sub_events( return subs, total async def get_billing_portal_url( - self, org: Organization + self, org: Organization, headers: dict[str, str] ) -> SubscriptionPortalUrlResponse: """Get subscription info, fetching portal url if available""" if not org.subscription: return SubscriptionPortalUrlResponse() + return_url = f"{get_origin(headers)}/orgs/{org.slug}/settings/billing" + if external_subs_app_api_url: try: req = SubscriptionPortalUrlRequest( - subId=org.subscription.subId, planId=org.subscription.planId + subId=org.subscription.subId, + planId=org.subscription.planId, + bytesStored=org.bytesStored, + execSeconds=self.org_ops.get_monthly_crawl_exec_seconds(org), + returnUrl=return_url, ) async with aiohttp.ClientSession() as session: async with session.request( @@ -388,8 +394,9 @@ async def get_sub_events( response_model=SubscriptionPortalUrlResponse, ) async def get_billing_portal_url( + request: Request, org: Organization = Depends(org_ops.org_owner_dep), ): - return await ops.get_billing_portal_url(org) + return await ops.get_billing_portal_url(org, dict(request.headers)) return ops diff --git a/backend/btrixcloud/utils.py b/backend/btrixcloud/utils.py index f6bafa4ef..79bb141d7 100644 --- a/backend/btrixcloud/utils.py +++ b/backend/btrixcloud/utils.py @@ -20,6 +20,9 @@ from slugify import slugify +default_origin = os.environ.get("APP_ORIGIN", "") + + class JSONSerializer(json.JSONEncoder): """Serializer class for json.dumps with UUID and datetime support""" @@ -180,3 +183,16 @@ def get_duplicate_key_error_field(err: DuplicateKeyError) -> str: except IndexError: pass return dupe_field + + +def get_origin(headers) -> str: + """Return origin of the received request""" + if not headers: + return default_origin + + scheme = headers.get("X-Forwarded-Proto") + host = headers.get("Host") + if not scheme or not host: + return default_origin + + return scheme + "://" + host diff --git a/backend/test/test_org_subs.py b/backend/test/test_org_subs.py index b6a1ccad1..c9731db83 100644 --- a/backend/test/test_org_subs.py +++ b/backend/test/test_org_subs.py @@ -276,6 +276,10 @@ def test_update_sub_2(admin_auth_headers): "futureCancelDate": None, # not updateable here, only by superadmin "readOnlyOnCancel": True, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + }, }, ) @@ -463,6 +467,7 @@ def test_subscription_events_log(admin_auth_headers, non_default_org_id): "status": "paused_payment_failed", "planId": "basic", "futureCancelDate": "2028-12-26T01:02:03", + "quotas": None, }, { "type": "update", @@ -471,6 +476,14 @@ def test_subscription_events_log(admin_auth_headers, non_default_org_id): "status": "active", "planId": "basic2", "futureCancelDate": None, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 0, + "maxExecMinutesPerMonth": 0, + }, }, {"subId": "123", "oid": new_subs_oid, "type": "cancel"}, {"subId": "234", "oid": new_subs_oid_2, "type": "cancel"}, @@ -522,6 +535,7 @@ def test_subscription_events_log_filter_sub_id(admin_auth_headers): "status": "paused_payment_failed", "planId": "basic", "futureCancelDate": "2028-12-26T01:02:03", + "quotas": None, }, { "type": "update", @@ -530,6 +544,14 @@ def test_subscription_events_log_filter_sub_id(admin_auth_headers): "status": "active", "planId": "basic2", "futureCancelDate": None, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 0, + "maxExecMinutesPerMonth": 0, + }, }, {"subId": "123", "oid": new_subs_oid, "type": "cancel"}, ] @@ -574,6 +596,7 @@ def test_subscription_events_log_filter_oid(admin_auth_headers): "status": "paused_payment_failed", "planId": "basic", "futureCancelDate": "2028-12-26T01:02:03", + "quotas": None, }, { "type": "update", @@ -582,6 +605,14 @@ def test_subscription_events_log_filter_oid(admin_auth_headers): "status": "active", "planId": "basic2", "futureCancelDate": None, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 0, + "maxExecMinutesPerMonth": 0, + }, }, {"subId": "123", "oid": new_subs_oid, "type": "cancel"}, ] @@ -609,7 +640,15 @@ def test_subscription_events_log_filter_plan_id(admin_auth_headers): "status": "active", "planId": "basic2", "futureCancelDate": None, - }, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 0, + "maxExecMinutesPerMonth": 0, + }, + } ] @@ -652,6 +691,14 @@ def test_subscription_events_log_filter_status(admin_auth_headers): "status": "active", "planId": "basic2", "futureCancelDate": None, + "quotas": { + "maxPagesPerCrawl": 50, + "storageQuota": 500000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + "maxConcurrentCrawls": 0, + "maxExecMinutesPerMonth": 0, + }, }, ] diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 20087f7f0..eb1a563ea 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -5,6 +5,7 @@ import { css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; +import { capitalize } from "lodash"; import { columns } from "../ui/columns"; @@ -171,7 +172,7 @@ export class OrgSettingsBilling extends TailwindElement { html`To upgrade to Pro, contact us at ${this.salesEmail}.`, @@ -195,6 +196,22 @@ export class OrgSettingsBilling extends TailwindElement { `; } + private getPlanName(planId: string) { + switch (planId) { + case "starter": + return msg("Starter"); + + case "standard": + return msg("Standard"); + + case "plus": + return msg("Plus"); + + default: + return capitalize(planId); + } + } + private readonly renderSubscriptionDetails = ( subscription: OrgData["subscription"], ) => { @@ -204,7 +221,7 @@ export class OrgSettingsBilling extends TailwindElement { if (subscription) { tierLabel = html` - ${msg("Starter")} + ${this.getPlanName(subscription.planId)} `; switch (subscription.status) { @@ -246,7 +263,7 @@ export class OrgSettingsBilling extends TailwindElement { diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts index 0076e172f..5d49c8de6 100644 --- a/frontend/src/types/billing.ts +++ b/frontend/src/types/billing.ts @@ -6,6 +6,7 @@ export enum SubscriptionStatus { export type Subscription = { status: SubscriptionStatus; + planId: string; futureCancelDate: null | string; // UTC datetime string };