Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
50b48d6
allow updating org quotas with shared secret as well as active user dep
emma-sg Oct 28, 2025
3d68200
add checkout endpoint for addon minutes
emma-sg Oct 28, 2025
f78402b
set up additional minute checkout interface when subscription is
emma-sg Oct 29, 2025
8e57b3d
add endpoint for additional minute pricing
emma-sg Oct 30, 2025
bb37526
add price fetching in FE & fix linting etc in backend changes
emma-sg Oct 30, 2025
0dab1d7
fix additional price usage
emma-sg Oct 30, 2025
8d0bc8a
fix autofetching price
emma-sg Oct 30, 2025
52548eb
fix various bugs and issues with cashew <-> btrix api calls
emma-sg Oct 30, 2025
6a42fed
allow shared secret when determining org for quotas update endpoint
emma-sg Nov 3, 2025
5ce84a1
add 100 min preset & note about minutes being adjustable during checkout
emma-sg Nov 3, 2025
73868f2
update orgs router to allow shared secret when determining org
emma-sg Nov 4, 2025
6aa4ce5
show extra and gifted minutes in billing section
emma-sg Nov 4, 2025
5a08a3e
update error log
emma-sg Nov 4, 2025
b89d1ea
allow only quotas update endpoint to use shared secret, rather than
emma-sg Nov 5, 2025
1da2bbe
rename to `shared_secret_or_superuser` in `users.py`
emma-sg Nov 5, 2025
60dbb89
add optional "context" field to quota update log to store payment info
emma-sg Nov 5, 2025
202f9aa
rework input to accept context alongside quota updates in a single
emma-sg Nov 5, 2025
710e4b7
add new /add quotas endpoint & restore original set quotas endpoint
emma-sg Nov 5, 2025
856ef6e
make quota_updates update atomic in /quotas endpoint
emma-sg Nov 5, 2025
b8ca5d6
merge quota set & add methods into one quota update method
emma-sg Nov 10, 2025
93fd027
add `subscription_change` context to quota update when changing plan
emma-sg Nov 10, 2025
d6084da
fix type issue in auth.py
emma-sg Nov 10, 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
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 = None,
websocket: WebSocket | None = None,
) -> 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))
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(
{"_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