Skip to content

🔧 48015 backend [ billing ] Unlimited Free plan#270

Open
pneumojoseph wants to merge 3 commits into
masterfrom
backend/payments/48015__unlimited_free_plan
Open

🔧 48015 backend [ billing ] Unlimited Free plan#270
pneumojoseph wants to merge 3 commits into
masterfrom
backend/payments/48015__unlimited_free_plan

Conversation

@pneumojoseph

@pneumojoseph pneumojoseph commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Free-plan teams were subject to stricter usage limits than paid customers in several backend flows.

Fix / Solution

  • Raise the default team size limit for free-plan accounts from 5 to 1000 users.
  • Raise the user invite limit from 100 to 1000.
  • Billing behavior is now controlled per account via billing_sync.
  • Remove ProjectBillingPermission from payment endpoints so the Payments API is no longer blocked at the project level.
  • Update Stripe subscription sync to run only when the account billing plan is in BillingPlanType.PAYMENT_PLANS.
  • Tenant create/update/delete and signup Stripe sync with account-level billing_sync

Release notes

Free plan accounts now support up to 1000 users and 1000 invites. Billing remains controlled per account.

Changes

API

Accounts / plan

  • GET /v2/accounts/plan — Freemium accounts now return max_users = 1000 (via DEFAULT_MAX_USERS).

Tenants

  • POST /tenants — Stripe subscription purchase on tenant create is driven by account billing_sync only (global billing flag removed).
  • PATCH /tenants/{id} — Stripe subscription description update is driven by account billing_sync only.
  • DELETE /tenants/{id} — Stripe cancel / user-count sync is driven by account billing_sync only.

Payment

  • POST /payment/purchaseProjectBillingPermission removed; endpoint no longer returns 403 when global billing is disabled.
  • POST /payment/subscription/cancelProjectBillingPermission removed.
  • GET /payment/confirmProjectBillingPermission removed.
  • GET /payment/card-setupProjectBillingPermission removed.
  • GET /payment/productsProjectBillingPermission removed.
  • GET /payment/default-payment-methodProjectBillingPermission removed.
  • GET /payment/customer-portalProjectBillingPermission removed.
  • POST /payment/stripe/webhooksProjectBillingPermission removed; only StripeWebhookPermission applies.

Authentication / signup

  • Signup Stripe customer sync runs when billing_sync=True, regardless of the removed global billing flag.

Throttling

  • TokenThrottle / ApiKeyThrottle (via BaseAuthThrottle) — authenticated users on free plan are no longer rate-limited on throttled endpoints where skip_for_paid_accounts = True.

Admin

  • Account save in Django admin — Freemium and other non-Premium plans set max_users = 1000.

Web-client

No changes in this branch.

Test cases

API

GET /v2/accounts/plan

Authorization Test case Expected result
Authenticated user (Freemium account) Open account with billing_plan = free; call GET /v2/accounts/plan. 200; max_users = 1000; billing_plan = free; is_subscribed = false.
Authenticated user (Premium account) Open account with active Premium subscription; call GET /v2/accounts/plan. 200; max_users matches account value; subscription fields populated.
Not authenticated Call GET /v2/accounts/plan without token. 401.

POST /payment/purchase

Authorization Test case Expected result
Account owner, master account, billing_sync = true Send request with only required fields: success_url, products (one item with code, quantity). 200; payment link returned or empty 200 for off-session purchase.
Account owner, master account, billing_sync = true Send request with all fields: success_url, cancel_url, products. 200; response reflects successful purchase initiation.
Not authenticated Call POST /payment/purchase without token. 401.
Authenticated non-owner (admin or regular user) Call POST /payment/purchase as non-account-owner. 403.
Authenticated tenant user Call POST /payment/purchase from a tenant account. 403 (DisallowForTenantPermission).
Account owner, billing_sync = false Call POST /payment/purchase with valid payload. 400; body contains MSG_BL_0018 (billing disabled for account).
Account owner Send empty products array. 400; validation error (MSG_BL_0002).
Account owner Send products with non-existent price code. 400; validation error (MSG_BL_0003).
Account owner Omit success_url or send invalid URL. 400; DRF validation error on success_url.
Account owner Send products[].quantity = 0. 400; DRF validation error on quantity (min_value=1).

POST /payment/subscription/cancel

Authorization Test case Expected result
Account owner, master account, billing_sync = true Call POST /payment/subscription/cancel with empty body. 204; subscription cancel initiated.
Not authenticated Call endpoint without token. 401.
Authenticated non-owner Call endpoint as non-account-owner. 403.
Authenticated tenant user Call endpoint from tenant account. 403.
Account owner, billing_sync = false Call endpoint. 400; body contains MSG_BL_0018.

PATCH /tenants/{id}

Authorization Test case Expected result
Master account admin/owner, paid plan, active subscription Send { "tenant_name": "new name" }. 200; tenant_name updated in response.
Master account admin/owner Send request with only tenant_name (required for update). 200; other fields unchanged.
Not authenticated Call PATCH /tenants/{id} without token. 401.
Master account user (not admin/owner) Call endpoint as non-admin regular user. 403.
Tenant account user Call endpoint from tenant account (MasterAccountPermission). 403.
Master account admin, Freemium plan Call endpoint to update tenant name. 403 (BillingPlanPermission).
Master account admin, expired Premium subscription Call endpoint. 403 (ExpiredSubscriptionPermission).
Master account admin Update tenant belonging to another master account. 403 (MasterAccountAccessPermission).
Master account admin Send blank tenant_name: "". 400; validation error.
Master account admin Send tenant_name: null. 400; validation error.
Master account admin Stripe update_subscription_description raises exception. 400; validation error with Stripe message.

DELETE /tenants/{id}

Authorization Test case Expected result
Master account admin/owner, Premium plan Delete existing tenant of own master account. 204; tenant removed; user counts updated.
Master account admin/owner, Unlimited plan, billing_sync = true Delete tenant. 204; Stripe cancel called when applicable.
Master account admin/owner, Freemium plan Delete tenant. 204; no Stripe subscription changes.
Not authenticated Call DELETE /tenants/{id} without token. 401.
Master account user (not admin/owner) Call endpoint. 403.
Tenant account user Call endpoint from tenant account. 403.
Master account admin, expired Premium subscription Call endpoint. 403 (ExpiredSubscriptionPermission).
Master account admin Delete tenant of another master account. 403 (MasterAccountAccessPermission).
Master account admin Delete non-existent tenant id. 403 or 404 per existing behavior.
Master account admin Stripe cancel raises exception during delete. 400; validation error.

GET /payment/products

Authorization Test case Expected result
Authenticated admin or account owner, master account Call GET /payment/products. 200; active products with active/archived prices returned.
Not authenticated Call endpoint without token. 401.
Authenticated non-admin/non-owner Call endpoint as regular user. 403.
Authenticated tenant user Call endpoint from tenant account. 403.

GET /payment/card-setup

Authorization Test case Expected result
Account owner Call with required success_url and optional cancel_url. 200; setup_link returned.
Not authenticated Call without token. 401.
Authenticated non-owner Call as non-account-owner. 403.
Authenticated tenant user Call from tenant account. 403.
Account owner Omit success_url. 400; DRF validation error.
Account owner Send invalid success_url or cancel_url. 400; DRF validation error.

GET /payment/customer-portal

Authorization Test case Expected result
Account owner Call with required cancel_url. 200; portal link returned.
Not authenticated Call without token. 401.
Authenticated non-owner Call as non-account-owner. 403.
Authenticated tenant user Call from tenant account. 403.
Account owner Omit or send invalid cancel_url. 400; DRF validation error.

GET /payment/default-payment-method

Authorization Test case Expected result
Authenticated admin or account owner Call endpoint when Stripe payment method exists. 200; card details (last4, brand) returned.
Authenticated admin or account owner Call when no default payment method. 404.
Not authenticated Call without token. 401.
Authenticated non-admin/non-owner Call as regular user. 403.
Authenticated tenant user Call from tenant account. 403.

GET /payment/confirm

Authorization Test case Expected result
Valid confirm token, billing_sync = true Call with valid token query param. 200; Stripe confirm executed.
Valid confirm token, billing_sync = false Call with valid token. 200; Stripe confirm skipped (no-op).
Any Omit token query param. 400; validation error (MSG_BL_0001 for invalid/missing token).
Any Send invalid token value. 400; validation error.

POST /payment/stripe/webhooks

Authorization Test case Expected result
Valid Stripe webhook signature POST valid Stripe event payload. 200; webhook task enqueued.
Invalid / missing Stripe signature POST payload without valid webhook auth. 401.

Tenant creation (POST /tenants) — billing sync behavior

Authorization Test case Expected result
Master account admin, Premium, billing_sync = true Create tenant with valid payload. 201; Stripe subscription purchase triggered for tenant seats.
Master account admin, Premium, billing_sync = false Create tenant. 201; no Stripe subscription purchase.
Master account admin, Freemium / FractionalCOO master Create tenant under free-tier master. 201; tenant gets billing_plan = free, max_users = 1000.
Master account admin, Unlimited master, billing_sync = true Create tenant. 201; tenant billing_plan = null (requires purchase).

Invite / throttling (indirect)

Authorization Test case Expected result
Authenticated Freemium account owner Send multiple user invites rapidly (within throttle window). Invites accepted without paid-plan-only throttle skip; behavior matches updated BaseAuthThrottle (authenticated users skip throttling when skip_for_paid_accounts = True).
Authenticated Freemium account Invite users up to 1000 active users on free plan. Invites succeed until max_users limit; previously blocked at 5.

Web-client

No web-client changes in this branch. Optional smoke checks for pages that display plan limits from the API:

Browser Device Authorization Test scenario Expected result
Chrome Desktop browser Freemium account owner Open Team / billing-related UI that reads maxUsers from GET /v2/accounts/plan. UI shows allowance based on API max_users = 1000 (may differ from hardcoded frontend constant until a separate frontend change).
Chrome Desktop browser Freemium account owner Invite users beyond the old 5-user limit (up to API limit). Invites succeed; no false paywall from backend limits.
Chrome Desktop browser Not authenticated Open payment-related routes. Standard auth redirect; no regression from removed global billing block.

Note

High Risk
Payment endpoints and Stripe/subscription logic are no longer gated by the global BILLING flag; throttling no longer applies to free-plan authenticated users on default token throttles, which can affect abuse surface and billing consistency across deployments.

Overview
Raises free-plan capacity and shifts billing from a global feature flag to per-account billing_sync.

Limits: DEFAULT_MAX_USERS goes from 5 to 1000; production MAX_INVITES from 100 to 1000. Django admin sets max_users to 1000 for all non-Premium plans (no separate Freemium branch using the old default).

Billing gates: Removes ProjectBillingPermission and stops combining settings.PROJECT_CONF['BILLING'] with billing_sync in account, tenant, invite, transfer, signup, and convert flows—Stripe and seat updates run when billing_sync is true on the account. Signup defaults billing_sync from PROJECT_CONF['BILLING']; new accounts with billing_sync=False still get Freemium. Stripe subscription state sync in StripeService only runs when the plan is in BillingPlanType.PAYMENT_PLANS.

API behavior: Payment routes are no longer blocked when global billing is off; cancel/purchase still enforce billing_sync where applicable. BaseAuthThrottle skips rate limits for all authenticated users when skip_for_paid_accounts is true (not only paid accounts).

Tests drop global-BILLING mocks and scenarios that assumed project-level billing disabled.

Reviewed by Cursor Bugbot for commit 37967ba. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Remove global BILLING feature flag gate from all billing and payment logic

  • Removes ProjectBillingPermission from all payment endpoints (purchase, card setup, webhooks, subscription cancel, customer portal, etc.), making billing APIs accessible without a project-level BILLING flag.
  • Replaces all settings.PROJECT_CONF['BILLING'] checks in account, invite, transfer, and tenant services with direct checks on account.billing_sync, so billing behavior is now driven per-account rather than globally.
  • Raises DEFAULT_MAX_USERS from 5 to 1000 and MAX_INVITES from 100 to 1000 in settings.py, and sets non-PREMIUM accounts to max_users=1000 in the admin handler.
  • Updates BaseAuthThrottle.skip_condition in throttling.py to skip throttling for all authenticated users when skip_for_paid_accounts=True, regardless of payment status.
  • Behavioral Change: accounts on the UNLIMITED plan with billing_sync=False no longer inherit plan details from the master account; they receive default limits and no active trial.

Macroscope summarized 37967ba.

@pneumojoseph pneumojoseph self-assigned this Jul 13, 2026
@pneumojoseph pneumojoseph added the Backend API changes request label Jul 13, 2026
Comment thread backend/src/generics/throttling.py
Comment thread backend/src/accounts/tests/services/test_account.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit baaa949. Configure here.

Comment thread backend/src/settings.py
STRIPE_WEBHOOK_IP_WHITELIST = []

DEFAULT_MAX_USERS = 5
DEFAULT_MAX_USERS = 1000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Existing free accounts keep old limits

High Severity

Raising DEFAULT_MAX_USERS and production MAX_INVITES only changes defaults for new rows. Existing freemium accounts keep stored max_users / max_invites, so GET /v2/accounts/plan and invite limits still reflect the old caps. No data migration updates those accounts.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit baaa949. Configure here.

if obj.billing_plan == BillingPlanType.PREMIUM:
max_users = obj.get_paid_users_count()
elif obj.billing_plan == BillingPlanType.FREEMIUM:
max_users = settings.DEFAULT_MAX_USERS

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Admin hardcodes free plan limit

Low Severity

Non-premium admin saves now always set max_users to a literal 1000 instead of settings.DEFAULT_MAX_USERS. That duplicates the new default and can drift if the setting changes again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit baaa949. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend API changes request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant