Subscription Update Quotas (#1988)

- Follow-up to #1914, allows SubscriptionUpdate event to also update
quotas.
- Passes current usage info + current billing page URL to portalUrl
request for external app to be able to respond with best portalUrl
- get_origin() moved to utils to be available more generally.
- Updates billing tab to show current plans, switches order of quotas to
list execution time, storage first
This commit is contained in:
Ilya Kreymer 2024-08-05 15:59:47 -07:00 committed by GitHub
parent 0c29008b7d
commit 1c153dfd3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 120 additions and 35 deletions

View File

@ -14,7 +14,7 @@ from fastapi import HTTPException
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 @@ class EmailSender:
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 @@ class EmailSender:
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 @@ class EmailSender:
):
"""Send email to invite new user"""
origin = self.get_origin(headers)
origin = get_origin(headers)
receiver_email = invite.email or ""
@ -155,7 +139,7 @@ class EmailSender:
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,

View File

@ -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):

View File

@ -484,7 +484,14 @@ class OrgOps:
{"$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

View File

@ -11,7 +11,7 @@ import aiohttp
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 @@ class SubOps:
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 @@ def init_subs_api(
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

View File

@ -20,6 +20,9 @@ from pymongo.errors import DuplicateKeyError
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

View File

@ -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,
},
},
]

View File

@ -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
<a
class=${linkClassList}
href=${`mailto:${this.salesEmail}?subject=${msg(str`Upgrade Starter plan (${this.org?.name})`)}`}
href=${`mailto:${this.salesEmail}?subject=${msg(str`Upgrade Browsertrix plan (${this.org?.name})`)}`}
rel="noopener noreferrer nofollow"
>${this.salesEmail}</a
>.`,
@ -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`
<sl-icon class="text-neutral-500" name="nut"></sl-icon>
${msg("Starter")}
${this.getPlanName(subscription.planId)}
`;
switch (subscription.status) {
@ -246,7 +263,7 @@ export class OrgSettingsBilling extends TailwindElement {
<ul class="leading-relaxed text-neutral-700">
<li>
${msg(
str`${quotas.maxPagesPerCrawl ? formatNumber(quotas.maxPagesPerCrawl) : msg("Unlimited")} ${pluralOf("pages", quotas.maxPagesPerCrawl)} per crawl`,
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth * 60, undefined, undefined, "long") : msg("Unlimited minutes")} of crawling and QA analysis time per month`,
)}
</li>
<li>
@ -256,7 +273,12 @@ export class OrgSettingsBilling extends TailwindElement {
value=${quotas.storageQuota}
></sl-format-bytes>`
: msg("Unlimited")}
base disk space`,
storage`,
)}
</li>
<li>
${msg(
str`${quotas.maxPagesPerCrawl ? formatNumber(quotas.maxPagesPerCrawl) : msg("Unlimited")} ${pluralOf("pages", quotas.maxPagesPerCrawl)} per crawl`,
)}
</li>
<li>
@ -264,11 +286,6 @@ export class OrgSettingsBilling extends TailwindElement {
str`${quotas.maxConcurrentCrawls ? formatNumber(quotas.maxConcurrentCrawls) : msg("Unlimited")} concurrent ${pluralOf("crawls", quotas.maxConcurrentCrawls)}`,
)}
</li>
<li>
${msg(
str`${quotas.maxExecMinutesPerMonth ? humanizeSeconds(quotas.maxExecMinutesPerMonth * 60, undefined, undefined, "long") : msg("Unlimited minutes")} of base crawling time per month`,
)}
</li>
</ul>
`;

View File

@ -6,6 +6,7 @@ export enum SubscriptionStatus {
export type Subscription = {
status: SubscriptionStatus;
planId: string;
futureCancelDate: null | string; // UTC datetime string
};