mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 03:45:08 +08:00
refactor(api): standardize console billing portal errors (#41055)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -285,3 +285,20 @@ source_modules =
|
||||
services.recommended_app_catalog_gateway
|
||||
forbidden_modules =
|
||||
flask
|
||||
|
||||
[importlinter:contract:billing-application-boundary]
|
||||
name = Billing application services and ports are framework and infrastructure neutral
|
||||
type = forbidden
|
||||
source_modules =
|
||||
services.billing_portal_service
|
||||
services.partner_tenant_binding_service
|
||||
forbidden_modules =
|
||||
configs
|
||||
controllers
|
||||
extensions
|
||||
flask
|
||||
models
|
||||
repositories
|
||||
services.billing_service
|
||||
sqlalchemy
|
||||
werkzeug
|
||||
|
||||
@@ -7,20 +7,23 @@ from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
from controllers.console.billing.error import (
|
||||
BillingOperationFailedErrorResponse,
|
||||
BillingUnavailableErrorResponse,
|
||||
BillingUnprocessableEntityErrorResponse,
|
||||
to_billing_request_error,
|
||||
)
|
||||
from enums import CloudPlan
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.errors.billing import BillingError
|
||||
|
||||
_BILLING_PORTAL_ALLOWED_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
|
||||
|
||||
class SubscriptionQuery(BaseModel):
|
||||
@@ -45,37 +48,78 @@ class BillingSubscriptionResponse(ResponseModel):
|
||||
|
||||
|
||||
register_schema_models(console_ns, SubscriptionQuery, PartnerTenantsPayload)
|
||||
register_response_schema_models(console_ns, BillingResponse, BillingInvoiceResponse, BillingSubscriptionResponse)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
BillingOperationFailedErrorResponse,
|
||||
BillingUnprocessableEntityErrorResponse,
|
||||
BillingUnavailableErrorResponse,
|
||||
BillingResponse,
|
||||
BillingInvoiceResponse,
|
||||
BillingSubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/billing/subscription")
|
||||
class Subscription(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(SubscriptionQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingSubscriptionResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@console_ns.response(403, "Forbidden")
|
||||
@console_ns.response(
|
||||
422,
|
||||
"Invalid subscription query",
|
||||
console_ns.models[BillingUnprocessableEntityErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
502,
|
||||
"Billing operation failed",
|
||||
console_ns.models[BillingOperationFailedErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
503,
|
||||
"Billing unavailable",
|
||||
console_ns.models[BillingUnavailableErrorResponse.__name__],
|
||||
)
|
||||
@console_account_admission(
|
||||
editions=frozenset({DeploymentEdition.CLOUD}),
|
||||
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
|
||||
)
|
||||
@model_validate(SubscriptionQuery)
|
||||
def get(self, req_data: SubscriptionQuery, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
return BillingService.get_subscription(req_data.plan, req_data.interval, current_user.email, current_tenant_id)
|
||||
def get(self, req_data: SubscriptionQuery, request_context: RequestContext):
|
||||
try:
|
||||
data = application_services().billing_portal.get_subscription(
|
||||
request_context,
|
||||
plan=req_data.plan,
|
||||
interval=req_data.interval,
|
||||
)
|
||||
except BillingError as error:
|
||||
raise to_billing_request_error(error) from error
|
||||
return dump_response(BillingSubscriptionResponse, data)
|
||||
|
||||
|
||||
@console_ns.route("/billing/invoices")
|
||||
class Invoices(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[BillingInvoiceResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
return BillingService.get_invoices(current_user.email, current_tenant_id)
|
||||
@console_ns.response(403, "Forbidden")
|
||||
@console_ns.response(
|
||||
502,
|
||||
"Billing operation failed",
|
||||
console_ns.models[BillingOperationFailedErrorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(
|
||||
503,
|
||||
"Billing unavailable",
|
||||
console_ns.models[BillingUnavailableErrorResponse.__name__],
|
||||
)
|
||||
@console_account_admission(
|
||||
editions=frozenset({DeploymentEdition.CLOUD}),
|
||||
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,
|
||||
)
|
||||
def get(self, request_context: RequestContext):
|
||||
try:
|
||||
data = application_services().billing_portal.get_invoices(request_context)
|
||||
except BillingError as error:
|
||||
raise to_billing_request_error(error) from error
|
||||
return dump_response(BillingInvoiceResponse, data)
|
||||
|
||||
|
||||
@console_ns.route("/billing/partners/<string:partner_key>/tenants")
|
||||
@@ -86,20 +130,20 @@ class PartnerTenants(Resource):
|
||||
@console_ns.expect(console_ns.models[PartnerTenantsPayload.__name__])
|
||||
@console_ns.response(200, "Tenants synced to partner successfully", console_ns.models[BillingResponse.__name__])
|
||||
@console_ns.response(400, "Invalid partner information")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@with_current_user
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
@model_validate(PartnerTenantsPayload)
|
||||
def put(self, req_data: PartnerTenantsPayload, current_user: Account, partner_key: str):
|
||||
def put(self, req_data: PartnerTenantsPayload, request_context: RequestContext, partner_key: str):
|
||||
try:
|
||||
click_id = req_data.click_id
|
||||
decoded_partner_key = base64.b64decode(partner_key).decode("utf-8")
|
||||
except Exception as e:
|
||||
raise BadRequest("Invalid partner_key") from e
|
||||
|
||||
if not click_id or not decoded_partner_key or not current_user.id:
|
||||
if not click_id or not decoded_partner_key:
|
||||
raise BadRequest("Invalid partner information")
|
||||
|
||||
return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id)
|
||||
return application_services().partner_tenant_bindings.sync(
|
||||
account_id=request_context.account_id,
|
||||
partner_key=decoded_partner_key,
|
||||
click_id=click_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Literal
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.billing import (
|
||||
BillingError,
|
||||
BillingUpstreamInvalidResponseError,
|
||||
BillingUpstreamUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
class BillingUnprocessableEntityErrorResponse(ResponseModel):
|
||||
code: Literal["unprocessable_entity"]
|
||||
message: str
|
||||
status: Literal[422]
|
||||
|
||||
|
||||
class BillingOperationFailedErrorResponse(ResponseModel):
|
||||
code: Literal["billing_operation_failed"]
|
||||
message: str
|
||||
status: Literal[502]
|
||||
|
||||
|
||||
class BillingUnavailableErrorResponse(ResponseModel):
|
||||
code: Literal["billing_unavailable"]
|
||||
message: str
|
||||
status: Literal[503]
|
||||
|
||||
|
||||
class BillingOperationFailedError(BaseHTTPException):
|
||||
error_code = "billing_operation_failed"
|
||||
description = "We couldn't complete this request. Please try again. If the problem persists, contact support."
|
||||
code = 502
|
||||
|
||||
|
||||
class BillingUnavailableError(BaseHTTPException):
|
||||
error_code = "billing_unavailable"
|
||||
description = "This operation is temporarily unavailable. Please try again later."
|
||||
code = 503
|
||||
|
||||
|
||||
def to_billing_request_error(error: BillingError) -> BaseHTTPException:
|
||||
if isinstance(error, BillingUpstreamInvalidResponseError):
|
||||
return BillingOperationFailedError()
|
||||
if isinstance(error, BillingUpstreamUnavailableError):
|
||||
return BillingUnavailableError()
|
||||
raise TypeError(f"Unsupported billing error: {type(error).__name__}")
|
||||
@@ -20,7 +20,6 @@ from controllers.console.wraps import (
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
|
||||
@@ -403,6 +402,7 @@ class PreferredProviderTypeUpdateApi(Resource):
|
||||
|
||||
@console_ns.route("/workspaces/current/model-providers/<path:provider>/checkout-url")
|
||||
class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
@console_ns.doc(deprecated=True)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model provider checkout URL retrieved successfully",
|
||||
@@ -410,13 +410,13 @@ class ModelProviderPaymentCheckoutUrlApi(Resource):
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, provider: str):
|
||||
if provider != "anthropic":
|
||||
raise ValueError(f"provider name {provider} is invalid")
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=db.session())
|
||||
data = BillingService.get_model_provider_payment_link(
|
||||
provider_name=provider,
|
||||
tenant_id=current_tenant_id,
|
||||
|
||||
@@ -73,6 +73,8 @@ from services.auth.data_source_api_key_auth_gateways import (
|
||||
TenantApiKeyAuthCredentialEncryptor,
|
||||
)
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
from services.billing_service import BillingService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.errors.enterprise import EnterpriseServiceError
|
||||
from services.explore_banner_query_service import ExploreBannerQueryService
|
||||
@@ -81,6 +83,7 @@ from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.file_service import FileService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
from services.recommended_app_catalog_gateway import (
|
||||
BuiltinRecommendedAppCatalogGateway,
|
||||
RecommendedAppCatalogRouter,
|
||||
@@ -141,6 +144,7 @@ class ApplicationServices:
|
||||
accounts: AccountServices
|
||||
account_activation: AccountActivationService
|
||||
app_definitions: AppDefinitionQueryService
|
||||
billing_portal: BillingPortalService
|
||||
data_source_api_key_auth: DataSourceApiKeyAuthService
|
||||
webapp_access: WebAppAccessQueryService
|
||||
web_app_runtime: WebAppRuntimeQueryService
|
||||
@@ -149,6 +153,7 @@ class ApplicationServices:
|
||||
setup: SetupService
|
||||
feature_queries: FeatureQueryService
|
||||
init_validation: InitValidationService
|
||||
partner_tenant_bindings: PartnerTenantBindingService
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
trial_app_usage: TrialAppUsageRecorder
|
||||
workspace_queries: WorkspaceQueryService
|
||||
@@ -258,6 +263,11 @@ def build_application_services(
|
||||
dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/"
|
||||
),
|
||||
),
|
||||
billing_portal=BillingPortalService(
|
||||
accounts=accounts,
|
||||
get_subscription=BillingService.get_subscription,
|
||||
get_invoices=BillingService.get_invoices,
|
||||
),
|
||||
data_source_api_key_auth=DataSourceApiKeyAuthService(
|
||||
bindings=data_source_api_key_auth_bindings,
|
||||
validator=ProviderApiKeyAuthCredentialValidator(),
|
||||
@@ -296,6 +306,9 @@ def build_application_services(
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
partner_tenant_bindings=PartnerTenantBindingService(
|
||||
sync_bindings=BillingService.sync_partner_tenants_bindings,
|
||||
),
|
||||
recommended_app_queries=RecommendedAppQueryService(
|
||||
catalog=recommended_app_catalog,
|
||||
trial_apps=TrialAppQueryRepository(session_factory=database_client),
|
||||
|
||||
@@ -4998,6 +4998,9 @@ Restore a published workflow version into the draft workflow
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [BillingInvoiceResponse](#billinginvoiceresponse)<br> |
|
||||
| 403 | Forbidden | |
|
||||
| 502 | Billing operation failed | **application/json**: [BillingOperationFailedErrorResponse](#billingoperationfailederrorresponse)<br> |
|
||||
| 503 | Billing unavailable | **application/json**: [BillingUnavailableErrorResponse](#billingunavailableerrorresponse)<br> |
|
||||
|
||||
### [PUT] /billing/partners/{partner_key}/tenants
|
||||
Sync partner tenants bindings
|
||||
@@ -5034,6 +5037,10 @@ Sync partner tenants bindings
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [BillingSubscriptionResponse](#billingsubscriptionresponse)<br> |
|
||||
| 403 | Forbidden | |
|
||||
| 422 | Invalid subscription query | **application/json**: [BillingUnprocessableEntityErrorResponse](#billingunprocessableentityerrorresponse)<br> |
|
||||
| 502 | Billing operation failed | **application/json**: [BillingOperationFailedErrorResponse](#billingoperationfailederrorresponse)<br> |
|
||||
| 503 | Billing unavailable | **application/json**: [BillingUnavailableErrorResponse](#billingunavailableerrorresponse)<br> |
|
||||
|
||||
### [GET] /code-based-extension
|
||||
Get code-based extension data by module name
|
||||
@@ -10290,7 +10297,10 @@ Update a plugin endpoint
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Model provider summaries retrieved successfully | **application/json**: [ModelProviderSummaryListResponse](#modelprovidersummarylistresponse)<br> |
|
||||
|
||||
### [GET] /workspaces/current/model-providers/{provider}/checkout-url
|
||||
### ~~[GET] /workspaces/current/model-providers/{provider}/checkout-url~~
|
||||
|
||||
***DEPRECATED***
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
@@ -15452,6 +15462,14 @@ ExporleBanner status
|
||||
| enabled | boolean | Deprecated. Use system features deployment_edition to determine the product edition. | Yes |
|
||||
| subscription | [SubscriptionModel](#subscriptionmodel) | | Yes |
|
||||
|
||||
#### BillingOperationFailedErrorResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | | Yes |
|
||||
| message | string | | Yes |
|
||||
| status | integer | | Yes |
|
||||
|
||||
#### BillingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -15464,6 +15482,22 @@ ExporleBanner status
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| url | string | | Yes |
|
||||
|
||||
#### BillingUnavailableErrorResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | | Yes |
|
||||
| message | string | | Yes |
|
||||
| status | integer | | Yes |
|
||||
|
||||
#### BillingUnprocessableEntityErrorResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| code | string | | Yes |
|
||||
| message | string | | Yes |
|
||||
| status | integer | | Yes |
|
||||
|
||||
#### BinaryFileResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Application service for Console Billing portal links."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TypedDict
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.account_ports import AccountRepository
|
||||
|
||||
|
||||
class BillingPortalLink(TypedDict):
|
||||
url: str
|
||||
|
||||
|
||||
class BillingPortalService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
accounts: AccountRepository,
|
||||
get_subscription: Callable[[str, str, str, str], BillingPortalLink],
|
||||
get_invoices: Callable[[str, str], BillingPortalLink],
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._get_subscription = get_subscription
|
||||
self._get_invoices = get_invoices
|
||||
|
||||
def get_subscription(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
plan: str,
|
||||
interval: str,
|
||||
) -> BillingPortalLink:
|
||||
email, workspace_id = self._resolve_account_email_and_workspace_id(context)
|
||||
return self._get_subscription(plan, interval, email, workspace_id)
|
||||
|
||||
def get_invoices(self, context: RequestContext) -> BillingPortalLink:
|
||||
email, workspace_id = self._resolve_account_email_and_workspace_id(context)
|
||||
return self._get_invoices(email, workspace_id)
|
||||
|
||||
def _resolve_account_email_and_workspace_id(self, context: RequestContext) -> tuple[str, str]:
|
||||
workspace_id = context.active_workspace_id
|
||||
if workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise AccountNotFoundError
|
||||
return account.email, workspace_id
|
||||
@@ -2,12 +2,10 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal, NotRequired, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
@@ -15,7 +13,12 @@ from core.helper.http_client_pooling import get_pooled_http_client
|
||||
from enums import CloudPlan
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
from models import Account
|
||||
from services.billing_portal_service import BillingPortalLink
|
||||
from services.errors.billing import (
|
||||
BillingUpstreamInvalidResponseError,
|
||||
BillingUpstreamUnavailableError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +34,12 @@ _http_client: httpx.Client = get_pooled_http_client(
|
||||
EmailFreezeType = Literal["freeze", "email_domain_suspended"]
|
||||
|
||||
|
||||
class _BillingHTTPStatusError(ValueError):
|
||||
def __init__(self, message: str, status_code: int):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class SubscriptionPlan(TypedDict):
|
||||
"""Tenant subscriptionplan information."""
|
||||
|
||||
@@ -38,6 +47,9 @@ class SubscriptionPlan(TypedDict):
|
||||
expiration_date: int
|
||||
|
||||
|
||||
_billing_portal_link_adapter = TypeAdapter(BillingPortalLink)
|
||||
|
||||
|
||||
class QuotaReserveResult(TypedDict):
|
||||
reservation_id: str
|
||||
available: int
|
||||
@@ -187,14 +199,6 @@ class AccountNotificationDict(TypedDict, total=False):
|
||||
notifications: list[dict]
|
||||
|
||||
|
||||
class UpsertNotificationDict(TypedDict):
|
||||
notification_id: str
|
||||
|
||||
|
||||
class BatchAddNotificationAccountsDict(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class DismissNotificationDict(TypedDict):
|
||||
success: bool
|
||||
|
||||
@@ -359,9 +363,11 @@ class BillingService:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_subscription(cls, plan: str, interval: str, prefilled_email: str = "", tenant_id: str = ""):
|
||||
def get_subscription(
|
||||
cls, plan: str, interval: str, prefilled_email: str = "", tenant_id: str = ""
|
||||
) -> BillingPortalLink:
|
||||
params = {"plan": plan, "interval": interval, "prefilled_email": prefilled_email, "tenant_id": tenant_id}
|
||||
return cls._send_request("GET", "/subscription/payment-link", params=params)
|
||||
return cls._send_billing_portal_request("/subscription/payment-link", params=params)
|
||||
|
||||
@classmethod
|
||||
def get_model_provider_payment_link(cls, provider_name: str, tenant_id: str, account_id: str, prefilled_email: str):
|
||||
@@ -374,9 +380,9 @@ class BillingService:
|
||||
return cls._send_request("GET", "/model-provider/payment-link", params=params)
|
||||
|
||||
@classmethod
|
||||
def get_invoices(cls, prefilled_email: str = "", tenant_id: str = ""):
|
||||
def get_invoices(cls, prefilled_email: str = "", tenant_id: str = "") -> BillingPortalLink:
|
||||
params = {"prefilled_email": prefilled_email, "tenant_id": tenant_id}
|
||||
return cls._send_request("GET", "/invoices", params=params)
|
||||
return cls._send_billing_portal_request("/invoices", params=params)
|
||||
|
||||
@classmethod
|
||||
def update_tenant_feature_plan_usage(
|
||||
@@ -444,7 +450,10 @@ class BillingService:
|
||||
url = f"{base_url or cls.base_url}{endpoint}"
|
||||
response = _http_client.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
|
||||
if method == "GET" and response.status_code != httpx.codes.OK:
|
||||
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
|
||||
raise _BillingHTTPStatusError(
|
||||
"Unable to retrieve billing information. Please try again later or contact support.",
|
||||
response.status_code,
|
||||
)
|
||||
if method == "PUT":
|
||||
if response.status_code == httpx.codes.INTERNAL_SERVER_ERROR:
|
||||
raise InternalServerError(
|
||||
@@ -459,21 +468,28 @@ class BillingService:
|
||||
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def is_tenant_owner_or_admin(current_user: Account, *, session: Session):
|
||||
tenant_id = current_user.current_tenant_id
|
||||
|
||||
join: TenantAccountJoin | None = session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not join:
|
||||
raise ValueError("Tenant account join not found")
|
||||
|
||||
if not TenantAccountRole.is_privileged_role(TenantAccountRole(join.role)):
|
||||
raise ValueError("Only team owner or team admin can perform this action")
|
||||
@classmethod
|
||||
def _send_billing_portal_request(
|
||||
cls,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict[str, str],
|
||||
) -> BillingPortalLink:
|
||||
try:
|
||||
response = cls._send_request("GET", endpoint, params=params)
|
||||
return _billing_portal_link_adapter.validate_python(response)
|
||||
except _BillingHTTPStatusError as error:
|
||||
if error.status_code in {httpx.codes.REQUEST_TIMEOUT, httpx.codes.TOO_MANY_REQUESTS} or (
|
||||
error.status_code >= 500
|
||||
):
|
||||
raise BillingUpstreamUnavailableError from error
|
||||
raise BillingUpstreamInvalidResponseError from error
|
||||
except httpx.RequestError as error:
|
||||
raise BillingUpstreamUnavailableError from error
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, ValidationError) as error:
|
||||
raise BillingUpstreamInvalidResponseError from error
|
||||
except ValueError as error:
|
||||
raise RuntimeError("Unexpected billing service value error") from error
|
||||
|
||||
@classmethod
|
||||
def delete_account(cls, account_id: str):
|
||||
@@ -744,49 +760,6 @@ class BillingService:
|
||||
"""
|
||||
return cls._send_request("GET", "/notifications/active", params={"account_id": account_id})
|
||||
|
||||
@classmethod
|
||||
def upsert_notification(
|
||||
cls,
|
||||
contents: list[LangContentDict],
|
||||
frequency: str = "once",
|
||||
status: str = "active",
|
||||
notification_id: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
) -> UpsertNotificationDict:
|
||||
"""Create or update a notification.
|
||||
|
||||
contents: list of {"lang": str, "title": str, "subtitle": str, "body": str, "title_pic_url": str}
|
||||
start_time / end_time: RFC3339 strings (e.g. "2026-03-01T00:00:00Z"), optional.
|
||||
Returns {"notification_id": str}.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"frequency": frequency,
|
||||
"status": status,
|
||||
}
|
||||
if notification_id:
|
||||
payload["notification_id"] = notification_id
|
||||
if start_time:
|
||||
payload["start_time"] = start_time
|
||||
if end_time:
|
||||
payload["end_time"] = end_time
|
||||
return cls._send_request("POST", "/notifications", json=payload)
|
||||
|
||||
@classmethod
|
||||
def batch_add_notification_accounts(
|
||||
cls, notification_id: str, account_ids: list[str]
|
||||
) -> BatchAddNotificationAccountsDict:
|
||||
"""Register target account IDs for a notification (max 1000 per call).
|
||||
|
||||
Returns {"count": int}.
|
||||
"""
|
||||
return cls._send_request(
|
||||
"POST",
|
||||
f"/notifications/{notification_id}/accounts",
|
||||
json={"account_ids": account_ids},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def dismiss_notification(cls, notification_id: str, account_id: str) -> DismissNotificationDict:
|
||||
"""Mark a notification as dismissed for an account.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class BillingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BillingUpstreamInvalidResponseError(BillingError):
|
||||
pass
|
||||
|
||||
|
||||
class BillingUpstreamUnavailableError(BillingError):
|
||||
pass
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Application service for partner tenant bindings."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PartnerTenantBindingService:
|
||||
def __init__(self, *, sync_bindings: Callable[[str, str, str], dict[str, Any]]) -> None:
|
||||
self._sync_bindings = sync_bindings
|
||||
|
||||
def sync(self, *, account_id: str, partner_key: str, click_id: str) -> dict[str, Any]:
|
||||
return self._sync_bindings(account_id, partner_key, click_id)
|
||||
@@ -1,14 +1,10 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
@@ -368,62 +364,3 @@ class TestBillingServiceGetPlanBulkWithCache:
|
||||
assert ttl_1_new <= 600
|
||||
assert ttl_2 > 0
|
||||
assert ttl_2 <= 600
|
||||
|
||||
|
||||
class TestBillingServiceIsTenantOwnerOrAdmin:
|
||||
"""
|
||||
Integration tests for BillingService.is_tenant_owner_or_admin.
|
||||
|
||||
Verifies that non-privileged roles (EDITOR, DATASET_OPERATOR) raise ValueError
|
||||
when checked against real TenantAccountJoin rows in PostgreSQL.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
yield
|
||||
db_session_with_containers.rollback()
|
||||
|
||||
def _create_account_with_tenant_role(self, db_session: Session, role: TenantAccountRole) -> tuple[Account, Tenant]:
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}")
|
||||
db_session.add(tenant)
|
||||
db_session.flush()
|
||||
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"billing_{uuid4()}@example.com",
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
db_session.add(account)
|
||||
db_session.flush()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=role,
|
||||
current=True,
|
||||
)
|
||||
db_session.add(join)
|
||||
db_session.flush()
|
||||
|
||||
# Wire up in-memory reference so current_tenant_id resolves
|
||||
account._current_tenant = tenant
|
||||
return account, tenant
|
||||
|
||||
def test_is_tenant_owner_or_admin_editor_role_raises_error(self, db_session_with_containers: Session) -> None:
|
||||
"""is_tenant_owner_or_admin raises ValueError for EDITOR role."""
|
||||
account, _ = self._create_account_with_tenant_role(db_session_with_containers, TenantAccountRole.EDITOR)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(account, session=db_session_with_containers)
|
||||
|
||||
def test_is_tenant_owner_or_admin_dataset_operator_raises_error(self, db_session_with_containers: Session) -> None:
|
||||
"""is_tenant_owner_or_admin raises ValueError for DATASET_OPERATOR role."""
|
||||
account, _ = self._create_account_with_tenant_role(
|
||||
db_session_with_containers, TenantAccountRole.DATASET_OPERATOR
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Only team owner or team admin can perform this action"):
|
||||
BillingService.is_tenant_owner_or_admin(account, session=db_session_with_containers)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@@ -8,10 +11,126 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, UnprocessableEntity
|
||||
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.billing.billing import PartnerTenants
|
||||
from enums import DeploymentEdition
|
||||
from controllers.console.billing.billing import Invoices, PartnerTenants, Subscription, SubscriptionQuery
|
||||
from controllers.console.billing.error import (
|
||||
BillingOperationFailedError,
|
||||
BillingUnavailableError,
|
||||
)
|
||||
from enums import CloudPlan, DeploymentEdition
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.model import DifySetup
|
||||
from services.errors.billing import (
|
||||
BillingUpstreamInvalidResponseError,
|
||||
BillingUpstreamUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
class TestBillingPortal:
|
||||
@pytest.fixture
|
||||
def app(self) -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def request_context(self) -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="tenant-1",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def billing_portal(self) -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_application_services(self, billing_portal: MagicMock) -> Iterator[None]:
|
||||
with patch(
|
||||
"controllers.console.billing.billing.application_services",
|
||||
return_value=SimpleNamespace(billing_portal=billing_portal),
|
||||
):
|
||||
yield
|
||||
|
||||
def test_get_subscription_uses_admission_context_and_response_contract(
|
||||
self,
|
||||
app: Flask,
|
||||
request_context: RequestContext,
|
||||
billing_portal: MagicMock,
|
||||
) -> None:
|
||||
resource = Subscription()
|
||||
method = unwrap(resource.get)
|
||||
query = SubscriptionQuery(plan=CloudPlan.PROFESSIONAL, interval="month")
|
||||
billing_portal.get_subscription.return_value = {"url": "https://billing.example.com/checkout"}
|
||||
|
||||
with app.test_request_context("/billing/subscription"):
|
||||
result = method(resource, query, request_context)
|
||||
|
||||
billing_portal.get_subscription.assert_called_once_with(
|
||||
request_context,
|
||||
plan=CloudPlan.PROFESSIONAL,
|
||||
interval="month",
|
||||
)
|
||||
assert result == {"url": "https://billing.example.com/checkout"}
|
||||
|
||||
def test_get_invoices_uses_admission_context_and_response_contract(
|
||||
self,
|
||||
app: Flask,
|
||||
request_context: RequestContext,
|
||||
billing_portal: MagicMock,
|
||||
) -> None:
|
||||
resource = Invoices()
|
||||
method = unwrap(resource.get)
|
||||
billing_portal.get_invoices.return_value = {"url": "https://billing.example.com/portal"}
|
||||
|
||||
with app.test_request_context("/billing/invoices"):
|
||||
result = method(resource, request_context)
|
||||
|
||||
billing_portal.get_invoices.assert_called_once_with(request_context)
|
||||
assert result == {"url": "https://billing.example.com/portal"}
|
||||
|
||||
def test_get_invoices_translates_unavailable_operation(
|
||||
self,
|
||||
app: Flask,
|
||||
request_context: RequestContext,
|
||||
billing_portal: MagicMock,
|
||||
) -> None:
|
||||
resource = Invoices()
|
||||
method = unwrap(resource.get)
|
||||
billing_portal.get_invoices.side_effect = BillingUpstreamUnavailableError
|
||||
|
||||
with app.test_request_context("/billing/invoices"):
|
||||
with pytest.raises(BillingUnavailableError) as exc_info:
|
||||
method(resource, request_context)
|
||||
|
||||
assert exc_info.value.data == {
|
||||
"code": "billing_unavailable",
|
||||
"message": "This operation is temporarily unavailable. Please try again later.",
|
||||
"status": 503,
|
||||
}
|
||||
|
||||
def test_get_subscription_translates_invalid_upstream_response(
|
||||
self,
|
||||
app: Flask,
|
||||
request_context: RequestContext,
|
||||
billing_portal: MagicMock,
|
||||
) -> None:
|
||||
resource = Subscription()
|
||||
method = unwrap(resource.get)
|
||||
query = SubscriptionQuery(plan=CloudPlan.PROFESSIONAL, interval="month")
|
||||
billing_portal.get_subscription.side_effect = BillingUpstreamInvalidResponseError
|
||||
|
||||
with app.test_request_context("/billing/subscription"):
|
||||
with pytest.raises(BillingOperationFailedError) as exc_info:
|
||||
method(resource, query, request_context)
|
||||
|
||||
assert exc_info.value.data == {
|
||||
"code": "billing_operation_failed",
|
||||
"message": "We couldn't complete this request. Please try again. If the problem persists, contact support.",
|
||||
"status": 502,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -55,10 +174,13 @@ class TestPartnerTenants:
|
||||
return account
|
||||
|
||||
@pytest.fixture
|
||||
def mock_billing_service(self):
|
||||
"""Mock BillingService."""
|
||||
with patch("controllers.console.billing.billing.BillingService") as mock_service:
|
||||
yield mock_service
|
||||
def partner_tenant_bindings(self):
|
||||
service = MagicMock()
|
||||
with patch(
|
||||
"controllers.console.billing.billing.application_services",
|
||||
return_value=SimpleNamespace(partner_tenant_bindings=service),
|
||||
):
|
||||
yield service
|
||||
|
||||
@pytest.fixture
|
||||
def mock_decorators(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
|
||||
@@ -74,14 +196,14 @@ class TestPartnerTenants:
|
||||
yield mock_csrf
|
||||
console_wraps._is_setup_completed.reset_success()
|
||||
|
||||
def test_put_success(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
def test_put_success(self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators):
|
||||
"""Test successful partner tenants bindings sync."""
|
||||
# Arrange
|
||||
partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8")
|
||||
click_id = "click-id-789"
|
||||
expected_response = {"result": "success", "data": {"synced": True}}
|
||||
|
||||
mock_billing_service.sync_partner_tenants_bindings.return_value = expected_response
|
||||
partner_tenant_bindings.sync.return_value = expected_response
|
||||
|
||||
with app.test_request_context(
|
||||
method="PUT",
|
||||
@@ -100,11 +222,13 @@ class TestPartnerTenants:
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_billing_service.sync_partner_tenants_bindings.assert_called_once_with(
|
||||
mock_account.id, "partner-key-123", click_id
|
||||
partner_tenant_bindings.sync.assert_called_once_with(
|
||||
account_id=mock_account.id,
|
||||
partner_key="partner-key-123",
|
||||
click_id=click_id,
|
||||
)
|
||||
|
||||
def test_put_invalid_partner_key_base64(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
def test_put_invalid_partner_key_base64(self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators):
|
||||
"""Test that invalid base64 partner_key raises BadRequest."""
|
||||
# Arrange
|
||||
invalid_partner_key = "invalid-base64-!@#$"
|
||||
@@ -129,7 +253,7 @@ class TestPartnerTenants:
|
||||
resource.put(invalid_partner_key)
|
||||
assert "Invalid partner_key" in str(exc_info.value)
|
||||
|
||||
def test_put_missing_click_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
def test_put_missing_click_id(self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators):
|
||||
"""Test that missing click_id raises UnprocessableEntity (422)."""
|
||||
# Arrange
|
||||
partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8")
|
||||
@@ -154,7 +278,7 @@ class TestPartnerTenants:
|
||||
resource.put(partner_key_encoded)
|
||||
|
||||
def test_put_billing_service_json_decode_error(
|
||||
self, app: Flask, mock_account, mock_billing_service, mock_decorators
|
||||
self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators
|
||||
):
|
||||
"""Test handling of billing service JSON decode error.
|
||||
|
||||
@@ -174,7 +298,7 @@ class TestPartnerTenants:
|
||||
# Simulate JSON decode error when billing service returns invalid JSON
|
||||
# This happens when billing service returns non-200 with empty/invalid response body
|
||||
json_decode_error = json.JSONDecodeError("Expecting value", "", 0)
|
||||
mock_billing_service.sync_partner_tenants_bindings.side_effect = json_decode_error
|
||||
partner_tenant_bindings.sync.side_effect = json_decode_error
|
||||
|
||||
with app.test_request_context(
|
||||
method="PUT",
|
||||
@@ -201,7 +325,7 @@ class TestPartnerTenants:
|
||||
assert isinstance(exc_info.value, json.JSONDecodeError)
|
||||
assert "Expecting value" in str(exc_info.value)
|
||||
|
||||
def test_put_empty_click_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
def test_put_empty_click_id(self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators):
|
||||
"""Test that empty click_id raises BadRequest."""
|
||||
# Arrange
|
||||
partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8")
|
||||
@@ -226,7 +350,9 @@ class TestPartnerTenants:
|
||||
resource.put(partner_key_encoded)
|
||||
assert "Invalid partner information" in str(exc_info.value)
|
||||
|
||||
def test_put_empty_partner_key_after_decode(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
def test_put_empty_partner_key_after_decode(
|
||||
self, app: Flask, mock_account, partner_tenant_bindings, mock_decorators
|
||||
):
|
||||
"""Test that empty partner_key after decode raises BadRequest."""
|
||||
# Arrange
|
||||
# Base64 encode an empty string
|
||||
@@ -251,29 +377,3 @@ class TestPartnerTenants:
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
resource.put(empty_partner_key_encoded)
|
||||
assert "Invalid partner information" in str(exc_info.value)
|
||||
|
||||
def test_put_empty_user_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators):
|
||||
"""Test that empty user id raises BadRequest."""
|
||||
# Arrange
|
||||
partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8")
|
||||
click_id = "click-id-789"
|
||||
mock_account.id = None # Empty user id
|
||||
|
||||
with app.test_request_context(
|
||||
method="PUT",
|
||||
json={"click_id": click_id},
|
||||
path=f"/billing/partners/{partner_key_encoded}/tenants",
|
||||
):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.wraps.current_account_with_tenant",
|
||||
return_value=(mock_account, mock_account.current_tenant_id),
|
||||
),
|
||||
patch("libs.login._get_user", return_value=mock_account),
|
||||
):
|
||||
resource = PartnerTenants()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
resource.put(partner_key_encoded)
|
||||
assert "Invalid partner information" in str(exc_info.value)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
|
||||
from controllers.console.billing.error import to_billing_request_error
|
||||
from services.errors.billing import BillingError
|
||||
|
||||
|
||||
def test_to_billing_request_error_rejects_unknown_error() -> None:
|
||||
with pytest.raises(TypeError, match="Unsupported billing error"):
|
||||
to_billing_request_error(BillingError())
|
||||
@@ -1,8 +1,8 @@
|
||||
from inspect import unwrap
|
||||
from unittest.mock import ANY, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask import Flask, g
|
||||
from pydantic_core import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
@@ -20,6 +20,7 @@ from controllers.console.workspace.model_providers import (
|
||||
PreferredProviderTypeUpdateApi,
|
||||
)
|
||||
from core.entities.provider_entities import CredentialConfiguration
|
||||
from enums import DeploymentEdition
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
|
||||
@@ -571,13 +572,8 @@ class TestModelProviderPaymentCheckoutUrlApi:
|
||||
method = unwrap(api.get)
|
||||
|
||||
user = make_account()
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.workspace.model_providers.BillingService.is_tenant_owner_or_admin",
|
||||
return_value=None,
|
||||
) as is_tenant_owner_or_admin,
|
||||
patch(
|
||||
"controllers.console.workspace.model_providers.BillingService.get_model_provider_payment_link",
|
||||
return_value={"payment_link": "https://payment.example.com/provider"},
|
||||
@@ -585,7 +581,6 @@ class TestModelProviderPaymentCheckoutUrlApi:
|
||||
):
|
||||
result = method(api, "tenant1", user, provider="anthropic")
|
||||
|
||||
is_tenant_owner_or_admin.assert_called_once_with(user, session=ANY)
|
||||
get_model_provider_payment_link.assert_called_once_with(
|
||||
provider_name="anthropic",
|
||||
tenant_id="tenant1",
|
||||
@@ -602,18 +597,21 @@ class TestModelProviderPaymentCheckoutUrlApi:
|
||||
with pytest.raises(ValueError):
|
||||
method(api, "tenant1", make_account(), provider="openai")
|
||||
|
||||
def test_permission_denied(self, app: Flask):
|
||||
def test_checkout_rejects_non_privileged_role(self, app: Flask):
|
||||
api = ModelProviderPaymentCheckoutUrlApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
user = make_account()
|
||||
account = make_account()
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch.object(dify_config, "LOGIN_DISABLED", True),
|
||||
patch.object(dify_config, "RBAC_ENABLED", False),
|
||||
patch(
|
||||
"controllers.console.workspace.model_providers.BillingService.is_tenant_owner_or_admin",
|
||||
side_effect=Forbidden(),
|
||||
),
|
||||
"controllers.console.workspace.model_providers.BillingService.get_model_provider_payment_link",
|
||||
) as get_model_provider_payment_link,
|
||||
):
|
||||
g._login_user = account
|
||||
with pytest.raises(Forbidden):
|
||||
method(api, "tenant1", user, provider="anthropic")
|
||||
api.get(provider="anthropic")
|
||||
|
||||
get_model_provider_payment_link.assert_not_called()
|
||||
|
||||
@@ -618,6 +618,69 @@ def test_console_member_invite_documents_bad_request_response():
|
||||
}
|
||||
|
||||
|
||||
def test_console_billing_routes_document_error_responses(monkeypatch: pytest.MonkeyPatch):
|
||||
from configs import dify_config
|
||||
from controllers.console import bp as console_bp
|
||||
|
||||
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
|
||||
app.register_blueprint(console_bp)
|
||||
|
||||
payload = app.test_client().get("/console/api/openapi.json").get_json()
|
||||
expected_responses = {
|
||||
("/billing/subscription", "get"): {
|
||||
"422": "BillingUnprocessableEntityErrorResponse",
|
||||
"502": "BillingOperationFailedErrorResponse",
|
||||
"503": "BillingUnavailableErrorResponse",
|
||||
},
|
||||
("/billing/invoices", "get"): {
|
||||
"502": "BillingOperationFailedErrorResponse",
|
||||
"503": "BillingUnavailableErrorResponse",
|
||||
},
|
||||
}
|
||||
|
||||
for (path, method), responses in expected_responses.items():
|
||||
operation = payload["paths"][path][method]
|
||||
for status, model_name in responses.items():
|
||||
schema = operation["responses"][status]["content"]["application/json"]["schema"]
|
||||
assert schema["$ref"] == f"#/components/schemas/{model_name}"
|
||||
|
||||
forbidden_response = operation["responses"]["403"]
|
||||
assert forbidden_response["description"] == "Forbidden"
|
||||
assert "content" not in forbidden_response
|
||||
|
||||
expected_error_contracts = {
|
||||
"BillingUnprocessableEntityErrorResponse": ("unprocessable_entity", 422),
|
||||
"BillingOperationFailedErrorResponse": ("billing_operation_failed", 502),
|
||||
"BillingUnavailableErrorResponse": ("billing_unavailable", 503),
|
||||
}
|
||||
schemas = payload["components"]["schemas"]
|
||||
for model_name, (error_code, status) in expected_error_contracts.items():
|
||||
properties = schemas[model_name]["properties"]
|
||||
assert properties["code"]["const"] == error_code
|
||||
assert properties["status"]["const"] == status
|
||||
|
||||
|
||||
def test_console_model_provider_checkout_route_is_deprecated(monkeypatch: pytest.MonkeyPatch):
|
||||
from configs import dify_config
|
||||
from controllers.console import bp as console_bp
|
||||
|
||||
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.config["RESTX_INCLUDE_ALL_MODELS"] = True
|
||||
app.register_blueprint(console_bp)
|
||||
|
||||
payload = app.test_client().get("/console/api/openapi.json").get_json()
|
||||
operation = payload["paths"]["/workspaces/current/model-providers/{provider}/checkout-url"]["get"]
|
||||
|
||||
assert operation["deprecated"] is True
|
||||
|
||||
|
||||
def test_console_plugin_category_list_exported_schema_uses_typed_items(tmp_path: Path):
|
||||
from dev.generate_swagger_specs import generate_specs
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions import ext_application_services
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account
|
||||
from models.model import AccountTrialAppRecord, DifySetup
|
||||
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
|
||||
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
|
||||
@@ -28,9 +30,12 @@ from services.account_activation_adapters import (
|
||||
)
|
||||
from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway
|
||||
from services.auth.data_source_api_key_auth_service import DataSourceApiKeyAuthService
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
from services.billing_service import BillingService
|
||||
from services.enterprise.enterprise_service import WebAppSettings
|
||||
from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPINotFoundError
|
||||
from services.init_validation_service import InvalidInitializationPasswordError
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
from services.tag_application_service import TagApplicationService
|
||||
from services.webapp_access_query_service import WebAppAccessUnavailableError
|
||||
|
||||
@@ -171,6 +176,63 @@ def test_build_application_services_wires_tag_boundary(
|
||||
assert isinstance(services.tags, TagApplicationService)
|
||||
|
||||
|
||||
def test_build_application_services_wires_billing_service(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
account = Account(name="Billing Owner", email="owner@example.com")
|
||||
account.id = "account-1"
|
||||
sqlite_session.add(account)
|
||||
sqlite_session.commit()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
BillingService,
|
||||
"get_subscription",
|
||||
return_value={"url": "https://billing.example.com/checkout"},
|
||||
) as get_subscription,
|
||||
patch.object(
|
||||
BillingService,
|
||||
"get_invoices",
|
||||
return_value={"url": "https://billing.example.com/portal"},
|
||||
) as get_invoices,
|
||||
patch.object(
|
||||
BillingService,
|
||||
"sync_partner_tenants_bindings",
|
||||
return_value={"result": "success"},
|
||||
) as sync_partner_tenants_bindings,
|
||||
):
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=MagicMock(spec=RedisClientWrapper),
|
||||
)
|
||||
|
||||
request_context = RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id="workspace-1",
|
||||
)
|
||||
assert isinstance(services.billing_portal, BillingPortalService)
|
||||
assert services.billing_portal.get_subscription(
|
||||
request_context,
|
||||
plan="professional",
|
||||
interval="month",
|
||||
) == {"url": "https://billing.example.com/checkout"}
|
||||
assert services.billing_portal.get_invoices(request_context) == {"url": "https://billing.example.com/portal"}
|
||||
assert isinstance(services.partner_tenant_bindings, PartnerTenantBindingService)
|
||||
assert services.partner_tenant_bindings.sync(
|
||||
account_id="account-1",
|
||||
partner_key="partner-key",
|
||||
click_id="click-1",
|
||||
) == {"result": "success"}
|
||||
get_subscription.assert_called_once_with("professional", "month", "owner@example.com", "workspace-1")
|
||||
get_invoices.assert_called_once_with("owner@example.com", "workspace-1")
|
||||
sync_partner_tenants_bindings.assert_called_once_with("account-1", "partner-key", "click-1")
|
||||
|
||||
|
||||
def test_build_application_services_wires_account_profile_repository(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
|
||||
@@ -447,6 +447,7 @@ class TestAccountService:
|
||||
|
||||
assert result is account
|
||||
assert result.current_tenant_id == tenant.id
|
||||
assert result.current_role == TenantAccountRole.NORMAL
|
||||
mock_refresh_last_active.assert_called_once_with(account, sqlite_session)
|
||||
|
||||
def test_load_user_not_found(self, sqlite_session: Session) -> None:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_errors import AccountNotFoundError
|
||||
from services.account_ports import AccountRepository
|
||||
from services.billing_portal_service import BillingPortalService
|
||||
from services.entities.account_entities import AccountSnapshot
|
||||
|
||||
|
||||
def _context(*, workspace_id: str | None = "workspace-1") -> RequestContext:
|
||||
return RequestContext(
|
||||
request_id="request-1",
|
||||
trace_id="trace-1",
|
||||
account_id="account-1",
|
||||
active_workspace_id=workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def _account() -> AccountSnapshot:
|
||||
return AccountSnapshot(
|
||||
id="account-1",
|
||||
name="Account",
|
||||
email="owner@example.com",
|
||||
avatar=None,
|
||||
is_password_set=False,
|
||||
interface_language="en-US",
|
||||
interface_theme="light",
|
||||
timezone="UTC",
|
||||
last_login_at=None,
|
||||
last_login_ip=None,
|
||||
status="active",
|
||||
initialized_at=None,
|
||||
created_at=datetime(2026, 1, 1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_subscription() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_invoices() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accounts() -> Mock:
|
||||
accounts = Mock(spec=AccountRepository)
|
||||
accounts.get.return_value = _account()
|
||||
return accounts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(accounts: Mock, get_subscription: MagicMock, get_invoices: MagicMock) -> BillingPortalService:
|
||||
return BillingPortalService(accounts=accounts, get_subscription=get_subscription, get_invoices=get_invoices)
|
||||
|
||||
|
||||
def test_get_subscription_loads_email_and_delegates(
|
||||
service: BillingPortalService,
|
||||
accounts: Mock,
|
||||
get_subscription: MagicMock,
|
||||
) -> None:
|
||||
get_subscription.return_value = {"url": "https://billing.example.com/checkout"}
|
||||
|
||||
result = service.get_subscription(
|
||||
_context(),
|
||||
plan="professional",
|
||||
interval="month",
|
||||
)
|
||||
|
||||
assert result == {"url": "https://billing.example.com/checkout"}
|
||||
accounts.get.assert_called_once_with("account-1")
|
||||
get_subscription.assert_called_once_with("professional", "month", "owner@example.com", "workspace-1")
|
||||
|
||||
|
||||
def test_get_invoices_loads_email_and_delegates(
|
||||
service: BillingPortalService,
|
||||
accounts: Mock,
|
||||
get_invoices: MagicMock,
|
||||
) -> None:
|
||||
get_invoices.return_value = {"url": "https://billing.example.com/portal"}
|
||||
|
||||
result = service.get_invoices(_context())
|
||||
|
||||
assert result == {"url": "https://billing.example.com/portal"}
|
||||
accounts.get.assert_called_once_with("account-1")
|
||||
get_invoices.assert_called_once_with("owner@example.com", "workspace-1")
|
||||
|
||||
|
||||
def test_missing_account_does_not_call_billing(
|
||||
service: BillingPortalService,
|
||||
accounts: Mock,
|
||||
get_invoices: MagicMock,
|
||||
) -> None:
|
||||
accounts.get.return_value = None
|
||||
|
||||
with pytest.raises(AccountNotFoundError):
|
||||
service.get_invoices(_context())
|
||||
|
||||
get_invoices.assert_not_called()
|
||||
|
||||
|
||||
def test_missing_workspace_does_not_query_account_or_billing(
|
||||
service: BillingPortalService,
|
||||
accounts: Mock,
|
||||
get_invoices: MagicMock,
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match="did not resolve an active workspace"):
|
||||
service.get_invoices(_context(workspace_id=None))
|
||||
|
||||
accounts.get.assert_not_called()
|
||||
get_invoices.assert_not_called()
|
||||
@@ -9,8 +9,7 @@ This test module covers all aspects of the billing service including:
|
||||
- Cache management for billing data
|
||||
- Partner integration features
|
||||
|
||||
Network, billing-provider, and cache boundaries are mocked; database authorization
|
||||
paths use isolated in-memory SQLite sessions with persisted membership rows.
|
||||
Network, billing-provider, and cache boundaries are mocked.
|
||||
Tests follow the Arrange-Act-Assert pattern for clarity.
|
||||
"""
|
||||
|
||||
@@ -20,15 +19,17 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from enums import CloudPlan
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from services.billing_service import BillingService
|
||||
from models import Account, Tenant
|
||||
from services.billing_service import BillingService, _BillingHTTPStatusError
|
||||
from services.errors.billing import (
|
||||
BillingUpstreamInvalidResponseError,
|
||||
BillingUpstreamUnavailableError,
|
||||
)
|
||||
|
||||
TENANT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
|
||||
@@ -41,43 +42,6 @@ def _account(*, account_id: str = ACCOUNT_ID, email: str = "user@example.com", t
|
||||
return account
|
||||
|
||||
|
||||
def _persist_membership(
|
||||
sqlite_session: Session,
|
||||
*,
|
||||
role: TenantAccountRole | None,
|
||||
add_other_tenant_membership: bool = False,
|
||||
) -> Account:
|
||||
account = _account()
|
||||
tenant = account.current_tenant
|
||||
assert tenant is not None
|
||||
sqlite_session.add_all([tenant, account])
|
||||
if role is not None:
|
||||
sqlite_session.add(
|
||||
TenantAccountJoin(
|
||||
tenant_id=TENANT_ID,
|
||||
account_id=ACCOUNT_ID,
|
||||
current=True,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
if add_other_tenant_membership:
|
||||
other_tenant = Tenant(name="Other Tenant")
|
||||
other_tenant.id = OTHER_TENANT_ID
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
other_tenant,
|
||||
TenantAccountJoin(
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
account_id=ACCOUNT_ID,
|
||||
current=False,
|
||||
role=TenantAccountRole.OWNER,
|
||||
),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
return account
|
||||
|
||||
|
||||
class TestBillingServiceSendRequest:
|
||||
"""Unit tests for BillingService._send_request method.
|
||||
|
||||
@@ -146,15 +110,16 @@ class TestBillingServiceSendRequest:
|
||||
"status_code", [httpx.codes.NOT_FOUND, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.BAD_REQUEST]
|
||||
)
|
||||
def test_get_request_non_200_status_code(self, mock_httpx_request, mock_billing_config, status_code):
|
||||
"""Test GET request with non-200 status code raises ValueError."""
|
||||
"""Test GET request preserves the upstream status for its public caller."""
|
||||
# Arrange
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = status_code
|
||||
mock_httpx_request.return_value = mock_response
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
with pytest.raises(_BillingHTTPStatusError) as exc_info:
|
||||
BillingService._send_request("GET", "/test")
|
||||
assert exc_info.value.status_code == status_code
|
||||
assert "Unable to retrieve billing information" in str(exc_info.value)
|
||||
|
||||
def test_put_request_success(self, mock_httpx_request, mock_billing_config):
|
||||
@@ -363,6 +328,85 @@ class TestBillingServiceSendRequest:
|
||||
assert mock_httpx_request.call_count > 1
|
||||
|
||||
|
||||
class TestBillingServicePortalRequest:
|
||||
def test_sends_get_request(self) -> None:
|
||||
params = {"tenant_id": "tenant-1"}
|
||||
with patch.object(
|
||||
BillingService,
|
||||
"_send_request",
|
||||
return_value={"url": "https://example.com", "ignored": True},
|
||||
) as send_request:
|
||||
result = BillingService._send_billing_portal_request("/test", params=params)
|
||||
|
||||
assert result == {"url": "https://example.com"}
|
||||
send_request.assert_called_once_with("GET", "/test", params=params)
|
||||
|
||||
def test_invalid_response_shape_is_invalid_upstream_response(self) -> None:
|
||||
with patch.object(BillingService, "_send_request", return_value={}):
|
||||
with pytest.raises(BillingUpstreamInvalidResponseError):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code",
|
||||
[httpx.codes.BAD_REQUEST, httpx.codes.UNAUTHORIZED, httpx.codes.NOT_FOUND],
|
||||
)
|
||||
def test_terminal_http_response_is_invalid_upstream_response(self, status_code: int) -> None:
|
||||
with patch.object(
|
||||
BillingService,
|
||||
"_send_request",
|
||||
side_effect=_BillingHTTPStatusError("request failed", status_code),
|
||||
):
|
||||
with pytest.raises(BillingUpstreamInvalidResponseError):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code",
|
||||
[httpx.codes.REQUEST_TIMEOUT, httpx.codes.TOO_MANY_REQUESTS, httpx.codes.INTERNAL_SERVER_ERROR],
|
||||
)
|
||||
def test_retryable_http_response_is_unavailable(self, status_code: int) -> None:
|
||||
with patch.object(
|
||||
BillingService,
|
||||
"_send_request",
|
||||
side_effect=_BillingHTTPStatusError("request failed", status_code),
|
||||
):
|
||||
with pytest.raises(BillingUpstreamUnavailableError):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
def test_transport_failure_is_unavailable(self) -> None:
|
||||
with patch.object(
|
||||
BillingService,
|
||||
"_send_request",
|
||||
side_effect=httpx.RequestError("network error"),
|
||||
):
|
||||
with pytest.raises(BillingUpstreamUnavailableError):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"decode_error",
|
||||
[
|
||||
json.JSONDecodeError("Expecting value", "", 0),
|
||||
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
|
||||
],
|
||||
)
|
||||
def test_invalid_payload_is_invalid_upstream_response(self, decode_error: Exception) -> None:
|
||||
with patch.object(BillingService, "_send_request", side_effect=decode_error):
|
||||
with pytest.raises(BillingUpstreamInvalidResponseError):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
def test_unknown_error_is_not_reclassified(self) -> None:
|
||||
with patch.object(BillingService, "_send_request", side_effect=RuntimeError("programming error")):
|
||||
with pytest.raises(RuntimeError, match="programming error"):
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
def test_unknown_value_error_is_not_exposed_as_invalid_request(self) -> None:
|
||||
original_error = ValueError("programming error")
|
||||
with patch.object(BillingService, "_send_request", side_effect=original_error):
|
||||
with pytest.raises(RuntimeError, match="Unexpected billing service value error") as exc_info:
|
||||
BillingService._send_billing_portal_request("/test", params={})
|
||||
|
||||
assert exc_info.value.__cause__ is original_error
|
||||
|
||||
|
||||
class TestBillingServiceSubscriptionInfo:
|
||||
"""Unit tests for subscription tier and billing info retrieval.
|
||||
|
||||
@@ -587,23 +631,22 @@ class TestBillingServiceSubscriptionInfo:
|
||||
assert result["limit"] == 100
|
||||
assert result["subscription_plan"] == CloudPlan.PROFESSIONAL
|
||||
|
||||
def test_get_subscription_payment_link(self, mock_send_request):
|
||||
def test_get_subscription_payment_link(self):
|
||||
"""Test subscription payment link generation."""
|
||||
# Arrange
|
||||
plan = "professional"
|
||||
interval = "monthly"
|
||||
interval = "month"
|
||||
email = "user@example.com"
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"payment_link": "https://payment.example.com/checkout"}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act
|
||||
result = BillingService.get_subscription(plan, interval, email, tenant_id)
|
||||
expected_response = {"url": "https://payment.example.com/checkout"}
|
||||
with patch.object(
|
||||
BillingService, "_send_billing_portal_request", return_value=expected_response
|
||||
) as send_request:
|
||||
result = BillingService.get_subscription(plan, interval, email, tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with(
|
||||
"GET",
|
||||
send_request.assert_called_once_with(
|
||||
"/subscription/payment-link",
|
||||
params={"plan": plan, "interval": interval, "prefilled_email": email, "tenant_id": tenant_id},
|
||||
)
|
||||
@@ -634,22 +677,20 @@ class TestBillingServiceSubscriptionInfo:
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_invoices(self, mock_send_request):
|
||||
def test_get_invoices(self):
|
||||
"""Test invoice retrieval."""
|
||||
# Arrange
|
||||
email = "user@example.com"
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"invoices": [{"id": "inv-1", "amount": 100}]}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act
|
||||
result = BillingService.get_invoices(email, tenant_id)
|
||||
expected_response = {"url": "https://payment.example.com/invoices"}
|
||||
with patch.object(
|
||||
BillingService, "_send_billing_portal_request", return_value=expected_response
|
||||
) as send_request:
|
||||
result = BillingService.get_invoices(email, tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with(
|
||||
"GET", "/invoices", params={"prefilled_email": email, "tenant_id": tenant_id}
|
||||
)
|
||||
send_request.assert_called_once_with("/invoices", params={"prefilled_email": email, "tenant_id": tenant_id})
|
||||
|
||||
|
||||
class TestBillingServiceUsageCalculation:
|
||||
@@ -1348,50 +1389,6 @@ class TestBillingServiceAccountManagement:
|
||||
"POST", "/account/delete-feedback", json={"email": email, "feedback": feedback}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, TenantAccountJoin)], indirect=True)
|
||||
def test_is_tenant_owner_or_admin_owner(self, sqlite_session: Session):
|
||||
"""Test tenant owner/admin check for owner role."""
|
||||
# Arrange
|
||||
current_user = _persist_membership(sqlite_session, role=TenantAccountRole.OWNER)
|
||||
|
||||
# Act - should not raise exception
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=sqlite_session)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, TenantAccountJoin)], indirect=True)
|
||||
def test_is_tenant_owner_or_admin_admin(self, sqlite_session: Session):
|
||||
"""Test tenant owner/admin check for admin role."""
|
||||
# Arrange
|
||||
current_user = _persist_membership(sqlite_session, role=TenantAccountRole.ADMIN)
|
||||
|
||||
# Act - should not raise exception
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=sqlite_session)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, TenantAccountJoin)], indirect=True)
|
||||
def test_is_tenant_owner_or_admin_normal_user_raises_error(self, sqlite_session: Session):
|
||||
"""Test tenant owner/admin check raises error for normal user."""
|
||||
# Arrange
|
||||
current_user = _persist_membership(sqlite_session, role=TenantAccountRole.NORMAL)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=sqlite_session)
|
||||
assert "Only team owner or team admin can perform this action" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, TenantAccountJoin)], indirect=True)
|
||||
def test_is_tenant_owner_or_admin_no_join_raises_error(self, sqlite_session: Session):
|
||||
"""Test tenant owner/admin check raises error when join not found."""
|
||||
# Arrange
|
||||
current_user = _persist_membership(
|
||||
sqlite_session,
|
||||
role=None,
|
||||
add_other_tenant_membership=True,
|
||||
)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService.is_tenant_owner_or_admin(current_user, session=sqlite_session)
|
||||
assert "Tenant account join not found" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestBillingServiceCacheManagement:
|
||||
"""Unit tests for billing cache management.
|
||||
@@ -1540,8 +1537,8 @@ class TestBillingServiceEdgeCases:
|
||||
"""Test subscription payment link with empty optional parameters."""
|
||||
# Arrange
|
||||
plan = "professional"
|
||||
interval = "yearly"
|
||||
expected_response = {"payment_link": "https://payment.example.com/checkout"}
|
||||
interval = "year"
|
||||
expected_response = {"url": "https://payment.example.com/checkout"}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act - empty email and tenant_id
|
||||
@@ -1558,7 +1555,7 @@ class TestBillingServiceEdgeCases:
|
||||
def test_get_invoices_with_empty_params(self, mock_send_request):
|
||||
"""Test invoice retrieval with empty parameters."""
|
||||
# Arrange
|
||||
expected_response = {"invoices": []}
|
||||
expected_response = {"url": "https://payment.example.com/invoices"}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act
|
||||
@@ -1566,7 +1563,6 @@ class TestBillingServiceEdgeCases:
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
assert result["invoices"] == []
|
||||
|
||||
def test_refund_with_invalid_history_id_format(self, mock_send_request):
|
||||
"""Test refund with various history ID formats."""
|
||||
@@ -1871,9 +1867,9 @@ class TestBillingServiceIntegrationScenarios:
|
||||
assert current_info["subscription"]["plan"] == "sandbox"
|
||||
|
||||
# Step 2: Get payment link for upgrade
|
||||
mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"}
|
||||
payment_link = BillingService.get_subscription("professional", "monthly", "user@example.com", tenant_id)
|
||||
assert "payment_link" in payment_link
|
||||
mock_send_request.return_value = {"url": "https://payment.example.com/upgrade"}
|
||||
payment_link = BillingService.get_subscription("professional", "month", "user@example.com", tenant_id)
|
||||
assert "url" in payment_link
|
||||
|
||||
# Step 3: Verify new rate limits after upgrade
|
||||
mock_send_request.return_value = {"limit": 100, "subscription_plan": CloudPlan.PROFESSIONAL}
|
||||
|
||||
@@ -8,6 +8,18 @@ export type BillingInvoiceResponse = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BillingOperationFailedErrorResponse = {
|
||||
code: 'billing_operation_failed'
|
||||
message: string
|
||||
status: 502
|
||||
}
|
||||
|
||||
export type BillingUnavailableErrorResponse = {
|
||||
code: 'billing_unavailable'
|
||||
message: string
|
||||
status: 503
|
||||
}
|
||||
|
||||
export type PartnerTenantsPayload = {
|
||||
click_id: string
|
||||
}
|
||||
@@ -20,6 +32,12 @@ export type BillingSubscriptionResponse = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BillingUnprocessableEntityErrorResponse = {
|
||||
code: 'unprocessable_entity'
|
||||
message: string
|
||||
status: 422
|
||||
}
|
||||
|
||||
export type GetBillingInvoicesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -27,6 +45,14 @@ export type GetBillingInvoicesData = {
|
||||
url: '/billing/invoices'
|
||||
}
|
||||
|
||||
export type GetBillingInvoicesErrors = {
|
||||
403: unknown
|
||||
502: BillingOperationFailedErrorResponse
|
||||
503: BillingUnavailableErrorResponse
|
||||
}
|
||||
|
||||
export type GetBillingInvoicesError = GetBillingInvoicesErrors[keyof GetBillingInvoicesErrors]
|
||||
|
||||
export type GetBillingInvoicesResponses = {
|
||||
200: BillingInvoiceResponse
|
||||
}
|
||||
@@ -64,6 +90,16 @@ export type GetBillingSubscriptionData = {
|
||||
url: '/billing/subscription'
|
||||
}
|
||||
|
||||
export type GetBillingSubscriptionErrors = {
|
||||
403: unknown
|
||||
422: BillingUnprocessableEntityErrorResponse
|
||||
502: BillingOperationFailedErrorResponse
|
||||
503: BillingUnavailableErrorResponse
|
||||
}
|
||||
|
||||
export type GetBillingSubscriptionError =
|
||||
GetBillingSubscriptionErrors[keyof GetBillingSubscriptionErrors]
|
||||
|
||||
export type GetBillingSubscriptionResponses = {
|
||||
200: BillingSubscriptionResponse
|
||||
}
|
||||
|
||||
@@ -9,6 +9,24 @@ export const zBillingInvoiceResponse = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* BillingOperationFailedErrorResponse
|
||||
*/
|
||||
export const zBillingOperationFailedErrorResponse = z.object({
|
||||
code: z.literal('billing_operation_failed'),
|
||||
message: z.string(),
|
||||
status: z.literal(502),
|
||||
})
|
||||
|
||||
/**
|
||||
* BillingUnavailableErrorResponse
|
||||
*/
|
||||
export const zBillingUnavailableErrorResponse = z.object({
|
||||
code: z.literal('billing_unavailable'),
|
||||
message: z.string(),
|
||||
status: z.literal(503),
|
||||
})
|
||||
|
||||
/**
|
||||
* PartnerTenantsPayload
|
||||
*/
|
||||
@@ -28,6 +46,15 @@ export const zBillingSubscriptionResponse = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* BillingUnprocessableEntityErrorResponse
|
||||
*/
|
||||
export const zBillingUnprocessableEntityErrorResponse = z.object({
|
||||
code: z.literal('unprocessable_entity'),
|
||||
message: z.string(),
|
||||
status: z.literal(422),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
|
||||
@@ -1098,8 +1098,12 @@ export const summary = {
|
||||
get: get13,
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const get14 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getWorkspacesCurrentModelProvidersByProviderCheckoutUrl',
|
||||
|
||||
Reference in New Issue
Block a user