Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0e3c0e9
allow updating org quotas with shared secret as well as active user dep
emma-sg Oct 28, 2025
a1d96ac
add checkout endpoint for addon minutes
emma-sg Oct 28, 2025
de164a5
set up additional minute checkout interface when subscription is
emma-sg Oct 29, 2025
7808d38
add endpoint for additional minute pricing
emma-sg Oct 30, 2025
2cba0b3
add price fetching in FE & fix linting etc in backend changes
emma-sg Oct 30, 2025
b89ba0a
fix additional price usage
emma-sg Oct 30, 2025
d4dae4d
fix autofetching price
emma-sg Oct 30, 2025
c9a5126
fix various bugs and issues with cashew <-> btrix api calls
emma-sg Oct 30, 2025
25039fb
allow shared secret when determining org for quotas update endpoint
emma-sg Nov 3, 2025
a634476
add 100 min preset & note about minutes being adjustable during checkout
emma-sg Nov 3, 2025
136615d
update orgs router to allow shared secret when determining org
emma-sg Nov 4, 2025
2900155
show extra and gifted minutes in billing section
emma-sg Nov 4, 2025
315781c
update error log
emma-sg Nov 4, 2025
b51b076
allow only quotas update endpoint to use shared secret, rather than
emma-sg Nov 5, 2025
f087571
rename to `shared_secret_or_superuser` in `users.py`
emma-sg Nov 5, 2025
79a5e6b
add optional "context" field to quota update log to store payment info
emma-sg Nov 5, 2025
7dba702
rework input to accept context alongside quota updates in a single
emma-sg Nov 5, 2025
39d9baa
add new /add quotas endpoint & restore original set quotas endpoint
emma-sg Nov 5, 2025
b7a9d21
make quota_updates update atomic in /quotas endpoint
emma-sg Nov 5, 2025
21d0eba
merge quota set & add methods into one quota update method
emma-sg Nov 10, 2025
1eaca0e
add `subscription_change` context to quota update when changing plan
emma-sg Nov 10, 2025
fd28bab
fix type issue in auth.py
emma-sg Nov 10, 2025
45bac57
revert type changes causing build failure
emma-sg Nov 12, 2025
19c8a8b
add test for adding quotas
emma-sg Nov 12, 2025
536c03f
fix ci subs tests
emma-sg Nov 17, 2025
53b7ab3
set up `btrix-subs-app-secret` in nightly test environment
emma-sg Nov 17, 2025
8410752
remove accidentally-copied microk8s prefix
emma-sg Nov 17, 2025
982ffeb
remove line breaks & fix env key
emma-sg Nov 17, 2025
dc7ed53
include reset back to prev value in "add to quota" test
emma-sg Nov 17, 2025
80e6f3d
remove unnecessary failing assertion that checked *used* execution
emma-sg Nov 17, 2025
9fa5509
move "add quota" test to end so it doesn't impact available execution
emma-sg Nov 17, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/k3d-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/k3d-nightly-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ jobs:
with:
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 }}
--from-literal=BTRIX_SUBS_APP_API_KEY=TEST_PRESHARED_SECRET_PASSWORD

- name: Start Cluster with Helm
run: |
helm upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml -f ./chart/test/test-nightly-addons.yaml btrix ./chart/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/microk8s-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
8 changes: 5 additions & 3 deletions backend/btrixcloud/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ class OA2BearerOrQuery(OAuth2PasswordBearer):
"""Override bearer check to also test query"""

async def __call__(
self, request: Request = None, websocket: WebSocket = None # type: ignore
self,
request: Request = None, # type: ignore
websocket: WebSocket = None, # type: ignore
) -> str:
param = None
exc = None
Expand Down Expand Up @@ -163,7 +165,7 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)

async def shared_secret_or_active_user(
async def shared_secret_or_superuser(
token: str = Depends(oauth2_scheme),
) -> User:
# allow superadmin access if token matches the known shared secret
Expand Down Expand Up @@ -257,4 +259,4 @@ async def refresh_jwt(user=Depends(current_active_user)):
user_info = await user_manager.get_user_info_with_orgs(user)
return get_bearer_response(user, user_info)

return auth_jwt_router, current_active_user, shared_secret_or_active_user
return auth_jwt_router, current_active_user, shared_secret_or_superuser
7 changes: 3 additions & 4 deletions backend/btrixcloud/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,7 @@ def main() -> None:

user_manager = init_user_manager(mdb, email, invites)

current_active_user, shared_secret_or_active_user = init_users_api(
app, user_manager
)
current_active_user, shared_secret_or_superuser = init_users_api(app, user_manager)

org_ops = init_orgs_api(
app,
Expand All @@ -183,9 +181,10 @@ def main() -> None:
crawl_manager,
invites,
current_active_user,
shared_secret_or_superuser,
)

init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_active_user)
init_subs_api(app, mdb, org_ops, user_manager, shared_secret_or_superuser)

event_webhook_ops = init_event_webhooks_api(mdb, org_ops, app_root)

Expand Down
27 changes: 27 additions & 0 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1857,6 +1857,8 @@ class OrgQuotasIn(BaseModel):
extraExecMinutes: Optional[int] = None
giftedExecMinutes: Optional[int] = None

context: str | None = None


# ============================================================================
class Plan(BaseModel):
Expand Down Expand Up @@ -1980,6 +1982,30 @@ class SubscriptionPortalUrlResponse(BaseModel):
portalUrl: str = ""


# ============================================================================
class AddonMinutesPricing(BaseModel):
"""Addon minutes pricing"""

value: float
currency: str


# ============================================================================
class CheckoutAddonMinutesRequest(BaseModel):
"""Request for additional minutes checkout session"""

orgId: str
subId: str
minutes: int | None = None
return_url: str


class CheckoutAddonMinutesResponse(BaseModel):
"""Response for additional minutes checkout session"""

checkoutUrl: str


# ============================================================================
class Subscription(BaseModel):
"""subscription data"""
Expand Down Expand Up @@ -2058,6 +2084,7 @@ class OrgQuotaUpdate(BaseModel):

modified: datetime
update: OrgQuotas
context: str | None = None


# ============================================================================
Expand Down
155 changes: 111 additions & 44 deletions backend/btrixcloud/orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,19 @@
from uuid import UUID, uuid4
from tempfile import NamedTemporaryFile

from typing import Optional, TYPE_CHECKING, Dict, Callable, List, AsyncGenerator, Any
from typing import (
Awaitable,
Optional,
TYPE_CHECKING,
Dict,
Callable,
List,
Literal,
AsyncGenerator,
Any,
)

from motor.motor_asyncio import AsyncIOMotorDatabase
from pydantic import ValidationError
from pymongo import ReturnDocument
from pymongo.collation import Collation
Expand Down Expand Up @@ -547,9 +558,15 @@ async def update_subscription_data(

org = Organization.from_dict(org_data)
if update.quotas:
# don't change gifted minutes here
# don't change gifted or extra minutes here
update.quotas.giftedExecMinutes = None
await self.update_quotas(org, update.quotas)
update.quotas.extraExecMinutes = None
await self.update_quotas(
org,
update.quotas,
mode="set",
context=f"subscription_change:{update.planId}",
)

return org

Expand Down Expand Up @@ -600,9 +617,17 @@ async def update_proxies(self, org: Organization, proxies: OrgProxies) -> None:
},
)

async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None:
async def update_quotas(
self,
org: Organization,
quotas: OrgQuotasIn,
mode: Literal["set", "add"],
context: str | None = None,
) -> None:
"""update organization quotas"""

quotas.context = None

previous_extra_mins = (
org.quotas.extraExecMinutes
if (org.quotas and org.quotas.extraExecMinutes)
Expand All @@ -614,51 +639,65 @@ async def update_quotas(self, org: Organization, quotas: OrgQuotasIn) -> None:
else 0
)

update = quotas.dict(
exclude_unset=True, exclude_defaults=True, exclude_none=True
)
if mode == "add":
increment_update: dict[str, Any] = {
"$inc": {},
}

quota_updates = []
for prev_update in org.quotaUpdates or []:
quota_updates.append(prev_update.dict())
quota_updates.append(OrgQuotaUpdate(update=update, modified=dt_now()).dict())
for field, value in quotas.model_dump(
exclude_unset=True, exclude_defaults=True, exclude_none=True
).items():
if field == "context" or value is None:
continue
inc = max(value, -org.quotas.model_dump().get(field, 0))
Copy link
Member

Choose a reason for hiding this comment

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

This is to avoid setting the quota to be <0, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

Exactly, it clips values below the the field's current value * -1 to that. Do you think we should error instead?

increment_update["$inc"][f"quotas.{field}"] = inc

await self.orgs.find_one_and_update(
{"_id": org.id},
{
"$set": {
"quotas": update,
"quotaUpdates": quota_updates,
}
updated_org = await self.orgs.find_one_and_update(
Copy link
Member

Choose a reason for hiding this comment

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

This is needed because we want to store the absolute quota update in quotaUpdates, so it needs to add first, then store the absolute entry in quotaUpdates, right?
As a result, we can't do it just one pass..

Copy link
Member Author

@emma-sg emma-sg Nov 18, 2025

Choose a reason for hiding this comment

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

Yeah, we hadn't previously had this come up because we were previously just receiving new absolute values. It might be possible to do it all within one update with $arrayElemAt or something, but I wasn't able to figure out a way to do it. The other option here would just be to wrap everything in a transaction — that might be the easiest solution here.

{"_id": org.id},
increment_update,
projection={"quotas": True},
return_document=ReturnDocument.AFTER,
)
quotas = OrgQuotasIn(**updated_org["quotas"])

update: dict[str, dict[str, dict[str, Any] | int]] = {
"$push": {
"quotaUpdates": OrgQuotaUpdate(
modified=dt_now(),
update=OrgQuotas(
**quotas.model_dump(
exclude_unset=True, exclude_defaults=True, exclude_none=True
)
),
context=context,
).model_dump()
},
)
"$inc": {},
"$set": {},
}

if mode == "set":
increment_update = quotas.model_dump(
exclude_unset=True, exclude_defaults=True, exclude_none=True
)
update["$set"]["quotas"] = increment_update

# Inc org available fields for extra/gifted execution time as needed
if quotas.extraExecMinutes is not None:
extra_secs_diff = (quotas.extraExecMinutes - previous_extra_mins) * 60
if org.extraExecSecondsAvailable + extra_secs_diff <= 0:
await self.orgs.find_one_and_update(
{"_id": org.id},
{"$set": {"extraExecSecondsAvailable": 0}},
)
update["$set"]["extraExecSecondsAvailable"] = 0
else:
await self.orgs.find_one_and_update(
{"_id": org.id},
{"$inc": {"extraExecSecondsAvailable": extra_secs_diff}},
)
update["$inc"]["extraExecSecondsAvailable"] = extra_secs_diff

if quotas.giftedExecMinutes is not None:
gifted_secs_diff = (quotas.giftedExecMinutes - previous_gifted_mins) * 60
if org.giftedExecSecondsAvailable + gifted_secs_diff <= 0:
await self.orgs.find_one_and_update(
{"_id": org.id},
{"$set": {"giftedExecSecondsAvailable": 0}},
)
update["$set"]["giftedExecSecondsAvailable"] = 0
else:
await self.orgs.find_one_and_update(
{"_id": org.id},
{"$inc": {"giftedExecSecondsAvailable": gifted_secs_diff}},
)
update["$inc"]["giftedExecSecondsAvailable"] = gifted_secs_diff

await self.orgs.find_one_and_update({"_id": org.id}, update)

async def update_event_webhook_urls(
self, org: Organization, urls: OrgWebhookUrls
Expand Down Expand Up @@ -1128,7 +1167,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"""
Expand Down Expand Up @@ -1436,10 +1475,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
Expand Down Expand Up @@ -1496,12 +1537,13 @@ async def inc_org_bytes_stored_field(self, oid: UUID, field: str, size: int):
# ============================================================================
# pylint: disable=too-many-statements, too-many-arguments
def init_orgs_api(
app,
mdb,
app: APIRouter,
mdb: AsyncIOMotorDatabase[Any],
user_manager: UserManager,
crawl_manager: CrawlManager,
invites: InviteOps,
user_dep: Callable,
user_dep: Callable[[str], Awaitable[User]],
superuser_or_shared_secret_dep: Callable[[str], Awaitable[User]],
):
"""Init organizations api router for /orgs"""
# pylint: disable=too-many-locals,invalid-name
Expand All @@ -1520,6 +1562,20 @@ async def org_dep(oid: UUID, user: User = Depends(user_dep)):

return org

async def org_superuser_or_shared_secret_dep(
oid: UUID, user: User = Depends(superuser_or_shared_secret_dep)
):
org = await ops.get_org_for_user_by_id(oid, user)
if not org:
raise HTTPException(status_code=404, detail="org_not_found")
if not org.is_viewer(user):
raise HTTPException(
status_code=403,
detail="User does not have permission to view this organization",
)

return org

async def org_crawl_dep(
org: Organization = Depends(org_dep), user: User = Depends(user_dep)
):
Expand Down Expand Up @@ -1656,7 +1712,18 @@ async def update_quotas(
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Not Allowed")

await ops.update_quotas(org, quotas)
await ops.update_quotas(org, quotas, mode="set", context=quotas.context)

return {"updated": True}

@app.post(
"/orgs/{oid}/quotas/add", tags=["organizations"], response_model=UpdatedResponse
)
async def update_quotas_add(
quotas: OrgQuotasIn,
org: Organization = Depends(org_superuser_or_shared_secret_dep),
):
await ops.update_quotas(org, quotas, mode="add", context=quotas.context)

return {"updated": True}

Expand Down
Loading
Loading