Skip to content

license leanup and billing - #2287

Merged
ay-rod merged 1 commit into
mainfrom
frontend/license-cleanup-and-billing
Jul 17, 2026
Merged

license leanup and billing#2287
ay-rod merged 1 commit into
mainfrom
frontend/license-cleanup-and-billing

Conversation

@ay-rod

@ay-rod ay-rod commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary by cubic

Adds a Subscription settings page with a billing portal launcher, and fixes license product linking across versions so license plans match the latest products. Also simplifies license UI and centers button spinners while loading.

  • New Features

    • Adds a Subscription tab in Settings with an "Open billing portal" button using autumn-js/react (useCustomer.openCustomerPortal).
  • Bug Fixes

    • Resolves linked license internal IDs to external product IDs across versions in handleGetLicenseProducts, so older links still match the latest product list.

Written for commit b484878. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR deprecates the entity feature ID concept in favor of licenses, fixes a bug where versioned license plan links failed to resolve to the current product version, and adds a new Subscription settings section for billing portal access.

  • [Bug fixes] handleGetLicenseProducts now performs a two-step resolution: it maps stale license_internal_product_id values (pointing at older versions) to their current public product IDs via a new listProductsByInternalIds lookup, so versioned license plans are correctly surfaced in the UI.
  • [Improvements] The entity feature config in AdvancedSettings is soft-deprecated — it remains visible only for items that already have entity_feature_id set, preventing new usage without breaking existing plans.
  • [Improvements] A new "Subscription" tab is added to the Settings view, letting users open the Autumn billing portal directly from the app. The shared Button component also gets a loading-state UX fix: content is hidden (invisible) during loading and the spinner is absolutely centered, so button width no longer collapses.

Confidence Score: 3/5

Two active defects in the changed paths: the billing portal button gets permanently stuck in its loading state after a successful open, and the new product-resolution query fetches across all orgs without an org/env guard.

The SubscriptionSection missing finally block means the first time any real user clicks 'Open billing portal' and the call succeeds, they are left with an unresponsive, forever-spinning button. The listProductsByInternalIds call added to handleGetLicenseProducts omits the orgId/env filter that every other repo function in that file applies, which is a latent cross-org data concern even if the current input path reduces the practical risk.

vite/src/views/settings/sections/SubscriptionSection.tsx (loading state reset) and server/src/internal/products/internalHandlers/handleGetProducts.ts / server/src/internal/licenses/repos/planLicenseRepo.ts (missing org/env scope in the new DB query).

Important Files Changed

Filename Overview
server/src/internal/products/internalHandlers/handleGetProducts.ts Bug fix for versioned license plan matching: resolves stale internal_id links to current public product ids via a new DB lookup, but the new query lacks org/env scope
vite/src/views/settings/sections/SubscriptionSection.tsx New billing portal section added; isLoading is only reset in the catch block, leaving the button permanently in loading state after a successful openCustomerPortal() call
packages/ui/src/components/ui/button.tsx Loading state refactored: spinner is now absolutely centered and children are hidden (invisible) during loading, preventing button width from collapsing
vite/src/views/products/plan/components/edit-plan-feature/AdvancedSettings.tsx Entity feature config is deprecated in favor of licenses; showEntityFeature now only shows when item.entity_feature_id is already set, preventing new usage
vite/src/views/products/plan/hooks/useHasEntityFeatureId.ts Hook deleted as part of the entity feature deprecation; logic inlined into AdvancedSettings
vite/src/views/settings/SettingsView.tsx New Subscription tab added to settings navigation, wired to the new SubscriptionSection component
vite/src/components/v2/icons/LicenseSectionTag.tsx Icon removed from the Licenses section tag, leaving a text-only label

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as handleGetLicenseProducts
    participant Repo as planLicenseRepo
    participant PS as ProductService

    UI->>Repo: listCatalogByOrgEnv(orgId, env)
    Repo-->>UI: links[]

    UI->>Repo: listProductsByInternalIds(linkedInternalIds)
    Note over Repo: Resolves old internal_ids<br/>to current public ids<br/>(no org/env filter)
    Repo-->>UI: linkedProducts[]

    UI->>PS: listFull(orgId, env, all_versions)
    PS-->>UI: products[]

    UI->>UI: filter products by linkedExternalIds
    UI-->>UI: licenseProducts[]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as handleGetLicenseProducts
    participant Repo as planLicenseRepo
    participant PS as ProductService

    UI->>Repo: listCatalogByOrgEnv(orgId, env)
    Repo-->>UI: links[]

    UI->>Repo: listProductsByInternalIds(linkedInternalIds)
    Note over Repo: Resolves old internal_ids<br/>to current public ids<br/>(no org/env filter)
    Repo-->>UI: linkedProducts[]

    UI->>PS: listFull(orgId, env, all_versions)
    PS-->>UI: products[]

    UI->>UI: filter products by linkedExternalIds
    UI-->>UI: licenseProducts[]
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
vite/src/views/settings/sections/SubscriptionSection.tsx:11-18
**`isLoading` never resets on success**

`setIsLoading(false)` is only called in the `catch` block. If `openCustomerPortal()` resolves normally (e.g. opens a portal URL in a new tab), the caller returns to this page with the button permanently stuck in its loading/spinner state until the component unmounts.

### Issue 2 of 3
vite/src/views/settings/sections/SubscriptionSection.tsx:11-19
Add a `finally` block to reset the loading state after both success and failure so the button is never permanently stuck.

```suggestion
	const handleOpenPortal = async () => {
		setIsLoading(true);
		try {
			await openCustomerPortal();
		} catch {
			toast.error("Failed to open billing portal");
		} finally {
			setIsLoading(false);
		}
	};
```

### Issue 3 of 3
server/src/internal/products/internalHandlers/handleGetProducts.ts:116-122
**Missing org/env scope in `listProductsByInternalIds`**

The underlying query (`inArray(products.internal_id, internalProductIds)`) contains no `orgId` / `env` filter. While the input IDs are already org-scoped (they come from `listCatalogByOrgEnv`), an explicit scope guard would prevent cross-org data leakage if, for any reason, a `license_internal_product_id` from another org's plan ended up in the set — e.g., due to a future refactor or a shared-product scenario.

Reviews (1): Last reviewed commit: "license leanup and billing" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

@ay-rod
ay-rod requested a review from johnyeocx as a code owner July 17, 2026 12:39
@capy-ai

capy-ai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Capy auto-review is paused for this organization because the usage-cycle auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 7 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

Comment on lines +11 to +18
const handleOpenPortal = async () => {
setIsLoading(true);
try {
await openCustomerPortal();
} catch {
toast.error("Failed to open billing portal");
setIsLoading(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 isLoading never resets on success

setIsLoading(false) is only called in the catch block. If openCustomerPortal() resolves normally (e.g. opens a portal URL in a new tab), the caller returns to this page with the button permanently stuck in its loading/spinner state until the component unmounts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: vite/src/views/settings/sections/SubscriptionSection.tsx
Line: 11-18

Comment:
**`isLoading` never resets on success**

`setIsLoading(false)` is only called in the `catch` block. If `openCustomerPortal()` resolves normally (e.g. opens a portal URL in a new tab), the caller returns to this page with the button permanently stuck in its loading/spinner state until the component unmounts.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +11 to +19
const handleOpenPortal = async () => {
setIsLoading(true);
try {
await openCustomerPortal();
} catch {
toast.error("Failed to open billing portal");
setIsLoading(false);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Add a finally block to reset the loading state after both success and failure so the button is never permanently stuck.

Suggested change
const handleOpenPortal = async () => {
setIsLoading(true);
try {
await openCustomerPortal();
} catch {
toast.error("Failed to open billing portal");
setIsLoading(false);
}
};
const handleOpenPortal = async () => {
setIsLoading(true);
try {
await openCustomerPortal();
} catch {
toast.error("Failed to open billing portal");
} finally {
setIsLoading(false);
}
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: vite/src/views/settings/sections/SubscriptionSection.tsx
Line: 11-19

Comment:
Add a `finally` block to reset the loading state after both success and failure so the button is never permanently stuck.

```suggestion
	const handleOpenPortal = async () => {
		setIsLoading(true);
		try {
			await openCustomerPortal();
		} catch {
			toast.error("Failed to open billing portal");
		} finally {
			setIsLoading(false);
		}
	};
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +116 to 122
const linkedProducts = await planLicenseRepo.listProductsByInternalIds({
db,
internalProductIds: linkedInternalIds,
});
const linkedExternalIds = new Set(
linkedProducts.map((product) => product.id),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing org/env scope in listProductsByInternalIds

The underlying query (inArray(products.internal_id, internalProductIds)) contains no orgId / env filter. While the input IDs are already org-scoped (they come from listCatalogByOrgEnv), an explicit scope guard would prevent cross-org data leakage if, for any reason, a license_internal_product_id from another org's plan ended up in the set — e.g., due to a future refactor or a shared-product scenario.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/internal/products/internalHandlers/handleGetProducts.ts
Line: 116-122

Comment:
**Missing org/env scope in `listProductsByInternalIds`**

The underlying query (`inArray(products.internal_id, internalProductIds)`) contains no `orgId` / `env` filter. While the input IDs are already org-scoped (they come from `listCatalogByOrgEnv`), an explicit scope guard would prevent cross-org data leakage if, for any reason, a `license_internal_product_id` from another org's plan ended up in the set — e.g., due to a future refactor or a shared-product scenario.

How can I resolve this? If you propose a fix, please make it concise.

@charlietlamb

Copy link
Copy Markdown
Contributor

slop

@ay-rod
ay-rod merged commit acc296e into main Jul 17, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants