Repository navigation
Dispatch enterprise accounts to the enterprise allowance endpoint - #60
Conversation
Enterprise credentials never report a balance: the personal resource endpoint
answers with an empty Accounts structure for them, so parsing fails with
"积分查询失败: 积分接口返回缺少 Accounts 字段" and every consumer — the WebUI,
credit-aware routing and the check-in ledger — sees a credential worth nothing.
Tenant detection:
* enterprise_id_of() reads both naming conventions; the header builder and the
billing dispatch now share it so the two views cannot disagree.
* is_enterprise_account() also honours type in {ultimate, exclusive}, because
historical backups can carry the tier without the id.
* An empty account object no longer shadows a populated accounts[0], and an
account without a uid falls back to a real email while rejecting placeholders
such as "unknown" or "手动添加" that would merge unrelated accounts.
Billing dispatch:
* The enterprise branch runs before the personal path. The enterprise response
is a single cycle allowance (used = credit, total = limitNum), projected as one
segment so expiry-aware routing and dashboards stay unchanged.
* limitNum == -1 is reported as unlimited with no numeric balance rather than a
fabricated zero.
* A missing tenant id is resolved once via /console/accounts; a failed lookup
raises a readable error instead of falling back to the personal endpoint, which
would misreport an enterprise account as having no credits.
account_key is unchanged: the inputs and the hash are untouched, so existing
routing rules and the credit ledger keep working with no migration.
Verified on a live enterprise credential (1995.93 = 2000 - 4.07) and against
the 11-credential fixture, where every personal account still uses the personal
endpoint. 24 new tests; the pre-existing suite shows no new failures.
Reviewer's GuideEnterprise credentials are now detected consistently, optionally resolved to a tenant, and dispatched to the enterprise allowance endpoint while preserving the existing credit-segment consumers, account keys, and personal-account behavior. Sequence diagram for enterprise credit dispatchsequenceDiagram
participant Sync as _sync_credits
participant Profile as client_profiles
participant Billing as credits
participant Accounts as ConsoleAccounts
participant Enterprise as EnterpriseBilling
participant Personal as PersonalBilling
Sync->>Profile: _credential_account()
Sync->>Profile: enterprise_id_of(account)
alt Enterprise type without tenant id
Sync->>Profile: is_enterprise_account(account)
Sync->>Billing: resolve_enterprise_id(token, uid, domain)
Billing->>Accounts: GET /console/accounts
Accounts-->>Billing: enterprise_id
Billing-->>Sync: enterprise_id
else Tenant id already present
Sync->>Profile: is_enterprise_account(account)
end
Sync->>Billing: fetch_credits(token, uid, domain, enterprise_id)
alt enterprise_id is present
Billing->>Billing: fetch_enterprise_credits(...)
Billing->>Enterprise: POST /v2/billing/meter/get-enterprise-user-usage
Enterprise-->>Billing: credit, limitNum, cycleResetTime
Billing-->>Sync: one credit segment or unlimited result
else Personal account
Billing->>Personal: POST /v2/billing/meter/get-user-resource
Personal-->>Billing: personal package segments
Billing-->>Sync: personal credit segments
end
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 10 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="converter.py" line_range="1572" />
<code_context>
+ account = _credential_account(cm._session())
+ enterprise_id = enterprise_id_of(account)
+ if enterprise_id is None and is_enterprise_account(account):
+ enterprise_id = credits_mod.resolve_enterprise_id(token, uid=uid, domain=domain) or ""
+ balance = credits_mod.fetch_credits(
+ token, uid=uid, domain=domain,
</code_context>
<issue_to_address>
**Enterprise lookups use personal billing**
When an enterprise account has no stored tenant ID and `/console/accounts` fails or returns no ID, `resolve_enterprise_id()` returns `None`, and `or ""` passes an empty ID to `fetch_credits()`. The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.
Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling `fetch_credits()`.
Also at `app/credits.py:491`, `converter.py:1573-1577`.
</issue_to_address>
### Comment 2
<location path="app/credits.py" line_range="466" />
<code_context>
+ if unlimited:
+ # Do not fabricate a balance: an unlimited tenant has no numeric remainder.
+ return {"credits": None, "count": 0, "segments": [], "soonest_expiry": None,
+ "intl": is_international_host(host), "partial": False, "unlimited": True,
+ "enterprise": True}
+ remaining = max(usage["limit"] - usage["used"], 0.0)
</code_context>
<issue_to_address>
**Unlimited accounts appear exhausted**
When an enterprise usage response has `limitNum == -1`, `fetch_enterprise_credits()` returns `credits=None` and `unlimited=True`, but `CreditLedger.update_credits()` drops the unlimited marker. Ledger consumers cannot distinguish an unlimited allowance from a missing balance, and `CredentialPool._has_credit()` treats it as zero, excluding the account from paid-model routing.
Persist the unlimited marker in the ledger and make credit-aware routing treat an unlimited account as eligible.
Also at `app/credits.py:467`.
</issue_to_address>
### Comment 3
<location path="converter.py" line_range="253-255" />
<code_context>
account = _credential_account(data)
- return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+ uid = account.get("uid")
+ if not (isinstance(uid, str) and uid.strip()):
+ uid = _identity_email(account) or ""
+ return account_key(profile_for_auth(data.get("auth") or {}), uid,
+ enterprise_id_of(account))
</code_context>
<issue_to_address>
**Email identities fail credit sync**
When a credential has no UID but has a usable email, or has a numeric UID, `_credential_identity()` hashes the email or an empty fallback, while `_sync_credits()` reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.
Derive the sync identity from the same credential identity inputs used by `_credential_identity()`.
</issue_to_address>
### Comment 4
<location path="app/credits.py" line_range="457" />
<code_context>
+ if status != 200:
+ raise _enterprise_error(f"积分接口 HTTP {status}")
+ code = payload.get("code")
+ if code not in (0, None):
+ raise _enterprise_error(str(payload.get("msg") or f"积分接口 code={code}"))
+ usage = parse_enterprise_usage(payload)
</code_context>
<issue_to_address>
**Valid success code is rejected**
When the enterprise usage endpoint returns a valid payload with `code: 200`, `fetch_enterprise_credits()` rejects `code: 200` before parsing an otherwise valid allowance payload, so enterprise balance refresh fails even though the HTTP request succeeded.
Accept `200` as a success code when validating the enterprise usage payload.
</issue_to_address>
### Comment 5
<location path="app/credits.py" line_range="91" />
<code_context>
"x-domain": str(domain or ""),
+ # The website interceptor always carries the tenant id; enterprise endpoints
+ # reject the request without it.
+ "x-enterprise-id": str(enterprise_id or ""),
+ "x-tenant-id": str(enterprise_id or ""),
"user-agent": BROWSER_UA,
</code_context>
<issue_to_address>
**Enterprise check-in omits tenant headers**
When automatic check-in is enabled for an enterprise credential, `_checkin_request()` builds its own headers without `x-enterprise-id` or `x-tenant-id`, and `_sync_credits()` invokes check-in before resolving an ID for historical credentials. The enterprise check-in request is rejected, so enabled automatic check-in does not complete.
Resolve the enterprise ID before check-in and include it in the check-in request headers.
</issue_to_address>
### Comment 6
<location path="app/credits.py" line_range="412" />
<code_context>
+ for key in ("accounts", "Accounts"):
+ items = data.get(key)
+ if isinstance(items, list) and items and isinstance(items[0], dict):
+ return enterprise_id_of(items[0])
+ return None
+
</code_context>
<issue_to_address>
**Multiple tenants select the wrong allowance**
When `/console/accounts` returns multiple tenant accounts for a credential without a stored tenant ID, `enterprise_id_from_accounts()` always takes the first account, so a credential with no stored tenant ID is dispatched using whichever tenant appears first in the response, even when the credential belongs to another listed tenant. The displayed balance and credit-aware routing then use the wrong tenant’s allowance.
Select the tenant corresponding to the credential, or reject ambiguous account lists rather than silently using the first tenant.
</issue_to_address>
### Comment 7
<location path="converter.py" line_range="254" />
<code_context>
- return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+ uid = account.get("uid")
+ if not (isinstance(uid, str) and uid.strip()):
+ uid = _identity_email(account) or ""
+ return account_key(profile_for_auth(data.get("auth") or {}), uid,
+ enterprise_id_of(account))
</code_context>
<issue_to_address>
**UID-less credentials cannot be imported**
When a credential has a valid email and token but no `account.uid`, `validate_cred_data()` rejects the credential before import or seeding reaches `_credential_identity()` and its email fallback. The credential cannot enter the normal flow.
Allow valid UID-less credentials through validation so the email fallback can identify them.
Also at `converter.py:255`.
</issue_to_address>
### Comment 8
<location path="converter.py" line_range="252-253" />
<code_context>
+ """Hash the stable account identity, falling back to a real email without a uid."""
account = _credential_account(data)
- return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+ uid = account.get("uid")
+ if not (isinstance(uid, str) and uid.strip()):
+ uid = _identity_email(account) or ""
+ return account_key(profile_for_auth(data.get("auth") or {}), uid,
</code_context>
<issue_to_address>
**Numeric UIDs collapse account identities**
When a credential has a numeric `account.uid` and no usable email, `_credential_identity()` treats a numeric `uid` as missing and replaces it with an email or an empty string. Since `validate_cred_data()` accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.
Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.
</issue_to_address>
### Comment 9
<location path="converter.py" line_range="255" />
<code_context>
+ uid = account.get("uid")
+ if not (isinstance(uid, str) and uid.strip()):
+ uid = _identity_email(account) or ""
+ return account_key(profile_for_auth(data.get("auth") or {}), uid,
+ enterprise_id_of(account))
</code_context>
<issue_to_address>
**Duplicate accounts enter the pool**
When multiple existing credential files have no uid but share the same valid email and profile, `_credential_identity` now gives uid-less credentials with the same email a shared identity, but `CredentialPool.reload` checks and records duplicate identities only when `uid` is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.
Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.
</issue_to_address>
### Comment 10
<location path="converter.py" line_range="253-255" />
<code_context>
account = _credential_account(data)
- return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId"))
+ uid = account.get("uid")
+ if not (isinstance(uid, str) and uid.strip()):
+ uid = _identity_email(account) or ""
+ return account_key(profile_for_auth(data.get("auth") or {}), uid,
+ enterprise_id_of(account))
</code_context>
<issue_to_address>
**Existing account state becomes orphaned**
When a credential already has persisted state and gains a fallback email identity or stores its tenant only as `enterprise_id`, `_credential_identity()` now produces a different `account_key()` than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.
Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.
Also at `converter.py:256`.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 10 findings to address first, and if the tenant lookup or allowance parsing is wrong, the resulting balance is written to the credit ledger and may affect credit-aware routing until refreshed. Reverting stops the new dispatch, but does not itself undo the stored reading; a subsequent successful refresh can recompute it.
Blocking findings: converter.py:1572, app/credits.py:466, converter.py:255, app/credits.py:457, app/credits.py:91, and 5 more
| account = _credential_account(cm._session()) | ||
| enterprise_id = enterprise_id_of(account) | ||
| if enterprise_id is None and is_enterprise_account(account): | ||
| enterprise_id = credits_mod.resolve_enterprise_id(token, uid=uid, domain=domain) or "" |
There was a problem hiding this comment.
🟠 High · Enterprise lookups use personal billing
When an enterprise account has no stored tenant ID and /console/accounts fails or returns no ID, resolve_enterprise_id() returns None, and or "" passes an empty ID to fetch_credits(). The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.
Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling fetch_credits().
Also at app/credits.py:491, converter.py:1573-1577.
Prompt for AI agents
In `converter.py` at line 1572:
**Enterprise lookups use personal billing**
When an enterprise account has no stored tenant ID and `/console/accounts` fails or returns no ID, `resolve_enterprise_id()` returns `None`, and `or ""` passes an empty ID to `fetch_credits()`. The enterprise branch is skipped, so the personal endpoint returns a wrong balance or error instead of the readable tenant-lookup error.
Do not convert a failed tenant lookup to an empty string; propagate the lookup failure before calling `fetch_credits()`.
Also at `app/credits.py:491`, `converter.py:1573-1577`.| if not (isinstance(uid, str) and uid.strip()): | ||
| uid = _identity_email(account) or "" | ||
| return account_key(profile_for_auth(data.get("auth") or {}), uid, |
There was a problem hiding this comment.
🟠 High · Email identities fail credit sync
When a credential has no UID but has a usable email, or has a numeric UID, _credential_identity() hashes the email or an empty fallback, while _sync_credits() reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.
Derive the sync identity from the same credential identity inputs used by _credential_identity().
Prompt for AI agents
In `converter.py` at lines 253-255:
**Email identities fail credit sync**
When a credential has no UID but has a usable email, or has a numeric UID, `_credential_identity()` hashes the email or an empty fallback, while `_sync_credits()` reconstructs the identity from the UID header. The keys differ, so sync rejects the credential before updating its balance; binding the new key can also leave prior state behind.
Derive the sync identity from the same credential identity inputs used by `_credential_identity()`.| if status != 200: | ||
| raise _enterprise_error(f"积分接口 HTTP {status}") | ||
| code = payload.get("code") | ||
| if code not in (0, None): |
There was a problem hiding this comment.
🟡 Medium · Valid success code is rejected
When the enterprise usage endpoint returns a valid payload with code: 200, fetch_enterprise_credits() rejects code: 200 before parsing an otherwise valid allowance payload, so enterprise balance refresh fails even though the HTTP request succeeded.
Accept 200 as a success code when validating the enterprise usage payload.
Prompt for AI agents
In `app/credits.py` at line 457:
**Valid success code is rejected**
When the enterprise usage endpoint returns a valid payload with `code: 200`, `fetch_enterprise_credits()` rejects `code: 200` before parsing an otherwise valid allowance payload, so enterprise balance refresh fails even though the HTTP request succeeded.
Accept `200` as a success code when validating the enterprise usage payload.| "x-domain": str(domain or ""), | ||
| # The website interceptor always carries the tenant id; enterprise endpoints | ||
| # reject the request without it. | ||
| "x-enterprise-id": str(enterprise_id or ""), |
There was a problem hiding this comment.
🟡 Medium · Enterprise check-in omits tenant headers
When automatic check-in is enabled for an enterprise credential, _checkin_request() builds its own headers without x-enterprise-id or x-tenant-id, and _sync_credits() invokes check-in before resolving an ID for historical credentials. The enterprise check-in request is rejected, so enabled automatic check-in does not complete.
Resolve the enterprise ID before check-in and include it in the check-in request headers.
Prompt for AI agents
In `app/credits.py` at line 91:
**Enterprise check-in omits tenant headers**
When automatic check-in is enabled for an enterprise credential, `_checkin_request()` builds its own headers without `x-enterprise-id` or `x-tenant-id`, and `_sync_credits()` invokes check-in before resolving an ID for historical credentials. The enterprise check-in request is rejected, so enabled automatic check-in does not complete.
Resolve the enterprise ID before check-in and include it in the check-in request headers.| for key in ("accounts", "Accounts"): | ||
| items = data.get(key) | ||
| if isinstance(items, list) and items and isinstance(items[0], dict): | ||
| return enterprise_id_of(items[0]) |
There was a problem hiding this comment.
🟡 Medium · Multiple tenants select the wrong allowance
When /console/accounts returns multiple tenant accounts for a credential without a stored tenant ID, enterprise_id_from_accounts() always takes the first account, so a credential with no stored tenant ID is dispatched using whichever tenant appears first in the response, even when the credential belongs to another listed tenant. The displayed balance and credit-aware routing then use the wrong tenant’s allowance.
Select the tenant corresponding to the credential, or reject ambiguous account lists rather than silently using the first tenant.
Prompt for AI agents
In `app/credits.py` at line 412:
**Multiple tenants select the wrong allowance**
When `/console/accounts` returns multiple tenant accounts for a credential without a stored tenant ID, `enterprise_id_from_accounts()` always takes the first account, so a credential with no stored tenant ID is dispatched using whichever tenant appears first in the response, even when the credential belongs to another listed tenant. The displayed balance and credit-aware routing then use the wrong tenant’s allowance.
Select the tenant corresponding to the credential, or reject ambiguous account lists rather than silently using the first tenant.| return account_key(profile_for_auth(data.get("auth") or {}), account.get("uid"), account.get("enterpriseId")) | ||
| uid = account.get("uid") | ||
| if not (isinstance(uid, str) and uid.strip()): | ||
| uid = _identity_email(account) or "" |
There was a problem hiding this comment.
🟡 Medium · UID-less credentials cannot be imported
When a credential has a valid email and token but no account.uid, validate_cred_data() rejects the credential before import or seeding reaches _credential_identity() and its email fallback. The credential cannot enter the normal flow.
Allow valid UID-less credentials through validation so the email fallback can identify them.
Also at converter.py:255.
Prompt for AI agents
In `converter.py` at line 254:
**UID-less credentials cannot be imported**
When a credential has a valid email and token but no `account.uid`, `validate_cred_data()` rejects the credential before import or seeding reaches `_credential_identity()` and its email fallback. The credential cannot enter the normal flow.
Allow valid UID-less credentials through validation so the email fallback can identify them.
Also at `converter.py:255`.| uid = account.get("uid") | ||
| if not (isinstance(uid, str) and uid.strip()): |
There was a problem hiding this comment.
🟡 Medium · Numeric UIDs collapse account identities
When a credential has a numeric account.uid and no usable email, _credential_identity() treats a numeric uid as missing and replaces it with an email or an empty string. Since validate_cred_data() accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.
Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.
Prompt for AI agents
In `converter.py` at lines 252-253:
**Numeric UIDs collapse account identities**
When a credential has a numeric `account.uid` and no usable email, `_credential_identity()` treats a numeric `uid` as missing and replaces it with an email or an empty string. Since `validate_cred_data()` accepts numeric UIDs by converting them to strings, credentials with different numeric UIDs and no usable email can receive the same account key, merging their identity-based routing and account state.
Normalize a present UID to a string for identity hashing, and use the email fallback only when the UID is absent or blank.| uid = account.get("uid") | ||
| if not (isinstance(uid, str) and uid.strip()): | ||
| uid = _identity_email(account) or "" | ||
| return account_key(profile_for_auth(data.get("auth") or {}), uid, |
There was a problem hiding this comment.
🟡 Medium · Duplicate accounts enter the pool
When multiple existing credential files have no uid but share the same valid email and profile, _credential_identity now gives uid-less credentials with the same email a shared identity, but CredentialPool.reload checks and records duplicate identities only when uid is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.
Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.
Prompt for AI agents
In `converter.py` at line 255:
**Duplicate accounts enter the pool**
When multiple existing credential files have no uid but share the same valid email and profile, `_credential_identity` now gives uid-less credentials with the same email a shared identity, but `CredentialPool.reload` checks and records duplicate identities only when `uid` is truthy. Both files are admitted as separate credentials, so one account can be routed and synchronized multiple times.
Apply duplicate detection using the canonical identity whenever it is available, including for uid-less credentials.| if not (isinstance(uid, str) and uid.strip()): | ||
| uid = _identity_email(account) or "" | ||
| return account_key(profile_for_auth(data.get("auth") or {}), uid, |
There was a problem hiding this comment.
🟡 Medium · Existing account state becomes orphaned
When a credential already has persisted state and gains a fallback email identity or stores its tenant only as enterprise_id, _credential_identity() now produces a different account_key() than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.
Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.
Also at converter.py:256.
Prompt for AI agents
In `converter.py` at lines 253-255:
**Existing account state becomes orphaned**
When a credential already has persisted state and gains a fallback email identity or stores its tenant only as `enterprise_id`, `_credential_identity()` now produces a different `account_key()` than the prior calculation. Existing ledger state and account-scoped settings remain under the old key, and binding the new key can reset saved credit and check-in state.
Preserve the prior identity key for existing credentials or migrate their persisted state and settings to the new key.
Also at `converter.py:256`.An enterprise tenant with limitNum=-1 holds no countable remainder, yet the ledger dropped the unlimited/enterprise flags, so the WebUI rendered an indistinguishable dash and credit-aware routing treated the account as zero balance. Store both flags with a double gate (unlimited requires enterprise), let routing short-circuit on them instead of reading a fabricated number, exclude such accounts from the monetary conversion while reporting their count, and render the allowance as 无限 in the dashboard and credentials pages. Usage statistics still count these accounts unchanged.
Legacy versions wrote stats rows with an empty dimension_key when the model was unknown, and the dashboard merged them into the ranking where the WebUI rendered the entry as 未识别模型. Skip empty keys both when aggregating new records (the global row still counts the request) and when reading back historical rows.
Fixes #59
Enterprise credentials never report a balance: the personal resource endpoint answers with an empty
Accountsstructure for them, so parsing fails and every consumer (WebUI, credit-aware routing, check-in ledger) sees a credential worth nothing.What changed
Tenant detection (
app/client_profiles.py)enterprise_id_of()reads bothenterpriseIdandenterprise_id. The header builder and the billing dispatch now share it, so the two views cannot disagree.is_enterprise_account()also honourstype ∈ {ultimate, exclusive}, because historical backups can carry the tier without the id.Billing dispatch (
app/credits.py,converter.py)credit= used,limitNum= total), projected as one segment so expiry-aware routing and dashboards stay unchanged.limitNum == -1is reported as unlimited with no numeric balance rather than a fabricated zero./console/accounts. A failed lookup raises a readable error instead of falling back to the personal endpoint, which would misreport an enterprise account as having no credits.Identity robustness (
converter.py,app/auth_oauth.py)accountobject no longer shadows a populatedaccounts[0].uidfalls back to a real email while rejecting placeholders such asunknownor手动添加that would merge unrelated accounts.converter.py::_sync_credits()now passes the tenant id to the billing layer.Compatibility
account_keyis unchanged — inputs and hash untouched, so existing routing rules and the credit ledger keep working with no migration. Personal accounts still use the personal endpoint.Verification
Live enterprise credential:
1995.93(= 2000 − 4.07), previouslynull.Against the 11-credential fixture every
account_keystays byte-identical, all personal accounts still resolve throughRESOURCE_PATH, and the pre-existing suite shows no new failures (25 Windows-environment failures before and after). 24 new tests intests/test_enterprise_credits.pycover the detection matrix, all payload wrappings, the unlimited marker, the resolution fallback and the personal-path regression guard.Prior art
changexbc/workbuddy-switch→crates/wb-switch-core/src/modules/credits.rs::get_credit_expiry(their issue #167 documents the same dispatch).Summary by Sourcery
Dispatch enterprise accounts through tenant-aware allowance billing while preserving personal-account behavior and existing account identities.
New Features:
Bug Fixes:
Enhancements:
Tests: