From 0e3c0e96be21da4578176a8a10ef1b6604b60356 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 28 Oct 2025 17:57:45 -0400 Subject: [PATCH 01/31] allow updating org quotas with shared secret as well as active user dep --- backend/btrixcloud/main.py | 1 + backend/btrixcloud/orgs.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index 21a7f95bd7..76aab92493 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -183,6 +183,7 @@ def main() -> None: crawl_manager, invites, current_active_user, + shared_secret_or_active_user, ) init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_active_user) diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 8acb083b73..3793934efc 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -1128,7 +1128,7 @@ async def json_items_gen( yield b"\n" doc_index += 1 - yield f']{"" if skip_closing_comma else ","}\n'.encode("utf-8") + yield f"]{'' if skip_closing_comma else ','}\n".encode("utf-8") async def json_closing_gen() -> AsyncGenerator: """Async generator to close JSON document""" @@ -1436,10 +1436,12 @@ async def delete_org_and_data( async def recalculate_storage(self, org: Organization) -> dict[str, bool]: """Recalculate org storage use""" try: - total_crawl_size, crawl_size, upload_size = ( - await self.base_crawl_ops.calculate_org_crawl_file_storage( - org.id, - ) + ( + total_crawl_size, + crawl_size, + upload_size, + ) = await self.base_crawl_ops.calculate_org_crawl_file_storage( + org.id, ) profile_size = await self.profile_ops.calculate_org_profile_file_storage( org.id @@ -1502,6 +1504,7 @@ def init_orgs_api( crawl_manager: CrawlManager, invites: InviteOps, user_dep: Callable, + user_or_shared_secret_dep: Callable, ): """Init organizations api router for /orgs""" # pylint: disable=too-many-locals,invalid-name @@ -1651,7 +1654,7 @@ async def get_plans(user: User = Depends(user_dep)): async def update_quotas( quotas: OrgQuotasIn, org: Organization = Depends(org_owner_dep), - user: User = Depends(user_dep), + user: User = Depends(user_or_shared_secret_dep), ): if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") From a1d96acbc3b0f120f4d4bffc020cc0d86eb18c95 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 28 Oct 2025 18:44:53 -0400 Subject: [PATCH 02/31] add checkout endpoint for addon minutes - add get_checkout_url method to create checkout session for additional minutes - add /checkout/execution-minutes endpoint to handle addon purchase requests (this will need to be updated in dev/prod playbooks) - update CI workflows to remove portalUrl suffix from secret value - add test endpoint in echo server to mock checkout response --- .github/workflows/k3d-ci.yaml | 2 +- .github/workflows/microk8s-ci.yaml | 2 +- backend/btrixcloud/models.py | 15 +++++++++++ backend/btrixcloud/subs.py | 42 +++++++++++++++++++++++++++++- backend/test/echo_server.py | 9 +++++++ backend/test/test_org_subs.py | 10 +++++++ 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/.github/workflows/k3d-ci.yaml b/.github/workflows/k3d-ci.yaml index 30466d86a3..2a0bf58f0a 100644 --- a/.github/workflows/k3d-ci.yaml +++ b/.github/workflows/k3d-ci.yaml @@ -112,7 +112,7 @@ jobs: version: 3.10.2 - name: Create secret - run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/.github/workflows/microk8s-ci.yaml b/.github/workflows/microk8s-ci.yaml index 33810511a8..dbd41e38f9 100644 --- a/.github/workflows/microk8s-ci.yaml +++ b/.github/workflows/microk8s-ci.yaml @@ -60,7 +60,7 @@ jobs: cache-to: type=gha,scope=frontend,mode=max - name: Create Secret - run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }}/portalUrl + run: sudo microk8s kubectl create secret generic btrix-subs-app-secret --from-literal=BTRIX_SUBS_APP_URL=${{ env.ECHO_SERVER_HOST_URL }} - name: Start Cluster with Helm run: | diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index e729730c2b..bfea691b86 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -1980,6 +1980,21 @@ class SubscriptionPortalUrlResponse(BaseModel): portalUrl: str = "" +# ============================================================================ +class CheckoutAddonMinutesRequest(BaseModel): + """Request for additional minutes checkout session""" + + orgId: str + subId: str + minutes: int | None = None + + +class CheckoutAddonMinutesResponse(BaseModel): + """Response for additional minutes checkout session""" + + checkoutUrl: str + + # ============================================================================ class Subscription(BaseModel): """subscription data""" diff --git a/backend/btrixcloud/subs.py b/backend/btrixcloud/subs.py index 9180ae3878..2f0c98e42b 100644 --- a/backend/btrixcloud/subs.py +++ b/backend/btrixcloud/subs.py @@ -15,6 +15,8 @@ from .users import UserManager from .utils import is_bool, get_origin from .models import ( + CheckoutAddonMinutesRequest, + CheckoutAddonMinutesResponse, SubscriptionCreate, SubscriptionImport, SubscriptionUpdate, @@ -363,7 +365,7 @@ async def get_billing_portal_url( async with aiohttp.ClientSession() as session: async with session.request( "POST", - external_subs_app_api_url, + f"{external_subs_app_api_url}/portalUrl", headers={ "Authorization": "bearer " + external_subs_app_api_key }, @@ -378,6 +380,33 @@ async def get_billing_portal_url( return SubscriptionPortalUrlResponse() + async def get_checkout_url(self, org: Organization, minutes: int | None): + if not org.subscription: + raise HTTPException( + status_code=404, detail="Organization has no subscription" + ) + subscription_id = org.subscription.subId + + if external_subs_app_api_url: + try: + req = CheckoutAddonMinutesRequest( + orgId=str(org.id), subId=subscription_id, minutes=minutes + ) + async with aiohttp.ClientSession() as session: + async with session.request( + "POST", + f"{external_subs_app_api_url}/checkout/additionalMinutes", + headers={ + "Authorization": "bearer " + external_subs_app_api_key + }, + json=req.model_dump_json(), + raise_for_status=True, + ) as resp: + text = await resp.text() + return CheckoutAddonMinutesResponse.model_validate_json(text) + except Exception as exc: + print("Error fetching checkout url", exc) + # pylint: disable=invalid-name,too-many-arguments def init_subs_api( @@ -501,4 +530,15 @@ async def get_billing_portal_url( ): return await ops.get_billing_portal_url(org, dict(request.headers)) + @org_ops.router.get( + "/checkout/execution-minutes", + tags=["organizations"], + response_model=CheckoutAddonMinutesResponse, + ) + async def get_billing_portal_url( + minutes: int | None = None, + org: Organization = Depends(org_ops.org_owner_dep), + ): + return await ops.get_checkout_url(org, minutes) + return ops diff --git a/backend/test/echo_server.py b/backend/test/echo_server.py index 0da8715a4e..097bb2d301 100644 --- a/backend/test/echo_server.py +++ b/backend/test/echo_server.py @@ -2,6 +2,7 @@ """ A web server to record POST requests and return them on a GET request """ + from http.server import HTTPServer, BaseHTTPRequestHandler import json @@ -29,6 +30,14 @@ def do_POST(self): "utf-8" ) ) + elif self.path.endswith("/checkout/additionalMinutes"): + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps( + {"checkoutUrl": "https://checkout.example.com/path/"} + ).encode("utf-8") + ) else: self.end_headers() diff --git a/backend/test/test_org_subs.py b/backend/test/test_org_subs.py index 429a32bde9..455916c120 100644 --- a/backend/test/test_org_subs.py +++ b/backend/test/test_org_subs.py @@ -334,6 +334,16 @@ def test_get_billing_portal_url(admin_auth_headers, echo_server): assert r.json() == {"portalUrl": "https://portal.example.com/path/"} +def test_get_addon_minutes_checkout_url(admin_auth_headers, echo_server): + r = requests.get( + f"{API_PREFIX}/orgs/{new_subs_oid}/checkout/execution-minutes", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + + assert r.json() == {"checkoutUrl": "https://checkout.example.com/path/"} + + def test_cancel_sub_and_delete_org(admin_auth_headers): # cancel, resulting in org deletion r = requests.post( From de164a5f862852f6ee03c8f4fdb534fa5ece1c48 Mon Sep 17 00:00:00 2001 From: emma Date: Wed, 29 Oct 2025 18:35:29 -0400 Subject: [PATCH 03/31] set up additional minute checkout interface when subscription is available --- .../settings/components/billing-addon-link.ts | 144 ++++++++++++++++++ .../pages/org/settings/components/billing.ts | 30 ++-- frontend/src/pages/org/settings/settings.ts | 1 + frontend/src/theme.stylesheet.css | 13 +- frontend/src/types/billing.ts | 4 + 5 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 frontend/src/pages/org/settings/components/billing-addon-link.ts diff --git a/frontend/src/pages/org/settings/components/billing-addon-link.ts b/frontend/src/pages/org/settings/components/billing-addon-link.ts new file mode 100644 index 0000000000..e3fc23cbec --- /dev/null +++ b/frontend/src/pages/org/settings/components/billing-addon-link.ts @@ -0,0 +1,144 @@ +import { localized, msg } from "@lit/localize"; +import { Task, TaskStatus } from "@lit/task"; +import { type SlSelectEvent } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { type BillingAddonCheckout } from "@/types/billing"; +import appState from "@/utils/state"; + +const PRESET_MINUTES = [600, 1500, 3000]; +const PRICE_PER_MINUTE: { value: number; currency: string } | undefined = + undefined; + +@customElement("btrix-org-settings-billing-addon-link") +@localized() +export class OrgSettingsBillingAddonLink extends BtrixElement { + @state() + private lastClickedMinutesPreset: number | undefined = undefined; + + private readonly checkoutUrl = new Task(this, { + task: async ([minutes]) => { + if (!appState.settings?.billingEnabled || !appState.org?.subscription) + return; + + try { + const { checkoutUrl } = await this.getCheckoutUrl(minutes); + + if (checkoutUrl) { + return checkoutUrl; + } else { + throw new Error("Missing checkoutUrl"); + } + } catch (e) { + console.debug(e); + + throw new Error( + msg("Sorry, couldn't retrieve current plan at this time."), + ); + } + }, + args: () => [undefined] as readonly [number | undefined], + autoRun: false, + }); + private async getCheckoutUrl(minutes?: number | undefined) { + const params = new URLSearchParams(); + if (minutes) params.append("minutes", minutes.toString()); + return this.api.fetch( + `/orgs/${this.orgId}/checkout/execution-minutes?${params.toString()}`, + ); + } + + private readonly localizeMinutes = (minutes: number) => { + return this.localize.number(minutes, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }); + }; + + private async checkout(minutes?: number | undefined) { + await this.checkoutUrl.run([minutes]); + if (this.checkoutUrl.value) { + window.location.href = this.checkoutUrl.value; + } else { + this.notify.toast({ + message: msg("Sorry, checkout isn’t available at this time."), + id: "checkout-unavailable", + variant: "warning", + }); + } + } + + render() { + const priceForMinutes = (minutes: number) => { + if (!PRICE_PER_MINUTE) return; + return this.localize.number(minutes * PRICE_PER_MINUTE.value, { + style: "currency", + currency: PRICE_PER_MINUTE.currency, + }); + }; + const price = priceForMinutes(1); + return html` + { + this.lastClickedMinutesPreset = undefined; + await this.checkout(); + }} + size="small" + variant="text" + ?loading=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset === undefined} + ?disabled=${this.checkoutUrl.status === TaskStatus.PENDING && + this.lastClickedMinutesPreset !== undefined} + class="-ml-3" + > + ${msg("Add More Execution Minutes")} + +
+ { + this.lastClickedMinutesPreset = parseInt(e.detail.item.value); + await this.checkout(this.lastClickedMinutesPreset); + void e.detail.item.closest("sl-dropdown")!.hide(); + }} + > + + + ${msg("Preset minute amounts")} + + + + ${msg("Preset minute amounts")} + ${PRESET_MINUTES.map((m) => { + const minutes = this.localizeMinutes(m); + return html` + + ${minutes} + ${PRICE_PER_MINUTE && + html` + ${priceForMinutes(m)} + `} + + `; + })} + + + ${PRICE_PER_MINUTE && + html`
+ ${msg(html`${price} per minute`)} +
`} + `; + } +} diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 5c8035f73c..fb8ab82b9d 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -12,11 +12,10 @@ import { BtrixElement } from "@/classes/BtrixElement"; import { columns } from "@/layouts/columns"; import { SubscriptionStatus, type BillingPortal } from "@/types/billing"; import type { OrgData, OrgQuotas } from "@/types/org"; -import { humanizeSeconds } from "@/utils/executionTimeFormatter"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; -const linkClassList = tw`transition-color text-primary hover:text-primary-500`; +const linkClassList = tw`text-primary transition-colors hover:text-primary-600`; const manageLinkClasslist = clsx( linkClassList, tw`flex cursor-pointer items-center gap-2 p-2 text-sm font-semibold leading-none`, @@ -95,7 +94,12 @@ export class OrgSettingsBilling extends BtrixElement { ${columns([ [ html` -
+
@@ -194,6 +198,13 @@ export class OrgSettingsBilling extends BtrixElement { `, )} + ${when( + this.org?.subscription, + () => + html``, + )}
`, html` @@ -346,12 +357,11 @@ export class OrgSettingsBilling extends BtrixElement { private readonly renderQuotas = (quotas: OrgQuotas) => { const maxExecMinutesPerMonth = quotas.maxExecMinutesPerMonth && - humanizeSeconds( - quotas.maxExecMinutesPerMonth * 60, - this.localize.lang(), - undefined, - "long", - ); + this.localize.number(quotas.maxExecMinutesPerMonth, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }); const maxPagesPerCrawl = quotas.maxPagesPerCrawl && `${this.localize.number(quotas.maxPagesPerCrawl)} ${pluralOf("pages", quotas.maxPagesPerCrawl)}`; @@ -368,7 +378,7 @@ export class OrgSettingsBilling extends BtrixElement {