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:
parent
0c29008b7d
commit
1c153dfd3c
@ -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,
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -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>
|
||||
`;
|
||||
|
||||
|
@ -6,6 +6,7 @@ export enum SubscriptionStatus {
|
||||
|
||||
export type Subscription = {
|
||||
status: SubscriptionStatus;
|
||||
planId: string;
|
||||
futureCancelDate: null | string; // UTC datetime string
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user