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`