Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Subscription Update Quotas #1988

Merged
merged 10 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions backend/btrixcloud/emailsender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ""
Expand All @@ -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")
)
Expand Down Expand Up @@ -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)

Expand All @@ -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 ""

Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,7 @@ class SubscriptionUpdate(BaseModel):
planId: str

futureCancelDate: Optional[datetime] = None
quotas: Optional[OrgQuotas] = None


# ============================================================================
Expand Down Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion backend/btrixcloud/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions backend/btrixcloud/subs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions backend/btrixcloud/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions backend/test/test_org_subs.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,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",
Expand All @@ -471,6 +472,7 @@ def test_subscription_events_log(admin_auth_headers, non_default_org_id):
"status": "active",
"planId": "basic2",
"futureCancelDate": None,
"quotas": None,
},
{"subId": "123", "oid": new_subs_oid, "type": "cancel"},
{"subId": "234", "oid": new_subs_oid_2, "type": "cancel"},
Expand Down Expand Up @@ -522,6 +524,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",
Expand All @@ -530,6 +533,7 @@ def test_subscription_events_log_filter_sub_id(admin_auth_headers):
"status": "active",
"planId": "basic2",
"futureCancelDate": None,
"quotas": None,
},
{"subId": "123", "oid": new_subs_oid, "type": "cancel"},
]
Expand Down Expand Up @@ -574,6 +578,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",
Expand All @@ -582,6 +587,7 @@ def test_subscription_events_log_filter_oid(admin_auth_headers):
"status": "active",
"planId": "basic2",
"futureCancelDate": None,
"quotas": None,
},
{"subId": "123", "oid": new_subs_oid, "type": "cancel"},
]
Expand Down Expand Up @@ -609,6 +615,7 @@ def test_subscription_events_log_filter_plan_id(admin_auth_headers):
"status": "active",
"planId": "basic2",
"futureCancelDate": None,
"quotas": None,
},
]

Expand Down Expand Up @@ -652,6 +659,7 @@ def test_subscription_events_log_filter_status(admin_auth_headers):
"status": "active",
"planId": "basic2",
"futureCancelDate": None,
"quotas": None,
},
]

Expand Down
21 changes: 19 additions & 2 deletions frontend/src/pages/org/settings/components/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
>.`,
Expand All @@ -195,6 +196,22 @@ export class OrgSettingsBilling extends TailwindElement {
`;
}

private getPlanName(planId: string) {
Copy link
Member Author

@ikreymer ikreymer Aug 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this worth doing for localization, or just use capitalize() for now for maximum flexibility?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is worth doing, nice to have the hook when/if we implement localization later.

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"],
) => {
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum SubscriptionStatus {

export type Subscription = {
status: SubscriptionStatus;
planId: string;
futureCancelDate: null | string; // UTC datetime string
};

Expand Down
Loading