Skip to content

fix: keep catalog push alive when deletion checks fail - #2314

Closed
charlietlamb wants to merge 5 commits into
devfrom
charlie/mobbin-versioned-catalog
Closed

fix: keep catalog push alive when deletion checks fail#2314
charlietlamb wants to merge 5 commits into
devfrom
charlie/mobbin-versioned-catalog

Conversation

@charlietlamb

@charlietlamb charlietlamb commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Prevents catalog preview/push from crashing on partially-deleted products with missing entitlements.

  • feature deletion checks fail closed and defer deletion
  • plan deletion checks fail closed and defer deletion
  • catalog feature usage handles missing entitlements
  • built local atmn package with bun run build

Validation: focused atmn push tests pass; full Autumn typecheck is currently blocked by pre-existing dependency/type errors.


Summary by cubic

Keeps catalog preview/update/push running when deletion checks fail, and adds explicit plan versioning across catalog and billing sync. Stripe price mapping now pins the exact plan version, including schedule phases and renamed plans.

  • Bug Fixes

    • Deletion checks fail closed and defer deletes; push/preview no longer crash and expose deletion_check_failed flags.
    • Catalog feature usage tolerates missing entitlements when checking references.
    • Billing sync preserves the matched plan version from Stripe Price IDs and subscription schedules.
  • New Features

    • Support explicit plan versions in catalog preview/update and enforce sequential versioning (no gaps), including across renames (new_plan_id).
    • Order plan updates by dependencies and prior versions to pin same-batch license versions.
    • Push mapping improvements: support variant.version, additional_currencies, customize.upsert_licenses, and default license prepaidOnly.

Written for commit 222be1c. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR prevents catalog.preview and catalog.push from crashing when encountering partially-deleted products with missing entitlements, and adds first-class support for pushing explicit plan versions in a single batch.

  • Bug fixes: Guards product.entitlements with ?? [] in previewUpdateCatalog.ts to prevent crashes on null entitlements; wraps getFeatureDeletionInfo and getPlanDeletionInfo API calls in try/catch so failures defer deletion rather than aborting the whole push.
  • Improvements: Adds validateCatalogPlanVersionTargets to enforce sequential version numbering; extends the dependency sort to order earlier versions before later ones; adds a new upsertPlans branch to create versioned plans against an existing base; propagates version through billing sync params so Stripe price matching pins the correct plan version.
  • API changes: normalizePlanForCompare now includes prepaidOnly and customize on licenses; toApiCustomizePlan forwards additional_currencies; toCatalogVariantParams forwards version.

Confidence Score: 3/5

Server-side changes are safe to merge; the client-side deletion guard in push.ts behaves differently from what the PR description promises.

The server-side catalog and billing changes are well-structured and covered by new integration and unit tests. The push.ts fix is incomplete: when the deletion-check API call fails, the code returns canDelete: false and sets deletionCheckFailed: true, but that flag is never read anywhere in the prompt generation or execution pipeline. Both the feature path and the plan path present 'Delete permanently' as the default action when a check fails, which is the opposite of conservative.

packages/atmn/src/commands/push/push.ts and packages/atmn/src/commands/push/prompts.ts — the deletionCheckFailed/deletion_check_failed path needs to either auto-skip or route to a prompt that defaults to Skip.

Important Files Changed

Filename Overview
packages/atmn/src/commands/push/push.ts Adds fail-closed error handling for deletion checks and new plan-normalization fields; the deletionCheckFailed guard is stored but never read, causing both feature and plan prompt routing to default to "Delete permanently" when a check fails.
packages/atmn/src/commands/push/types.ts Adds deletionCheckFailed?: boolean to PlanDeleteInfo and a new "deletion_check_failed" reason to FeatureDeleteInfo; both additions are correct but the plan field is currently dead code.
server/src/internal/catalog/actions/catalogPlanDependencies.ts Adds previous-version ordering to the dependency sort and a new validateCatalogPlanVersionTargets that enforces sequential version numbering for new plan versions; logic is correct and well-tested.
server/src/internal/catalog/actions/catalogPlanPreflight.ts Calls validateCatalogPlanVersionTargets before virtual product construction and respects explicit version numbers on new plans; straightforward and correct.
server/src/internal/catalog/actions/updateCatalog/updateCatalog.ts Adds a new code path to create versioned plans against an existing base when the target version is absent; uses force_version: true and delegates to updateProduct, which is consistent with the existing versioning mechanism.
server/src/internal/catalog/actions/previewUpdateCatalog/previewUpdateCatalog.ts Guards product.entitlements with ?? [] to prevent crashes on partially-deleted products; targeted one-line fix that addresses the stated bug.
server/src/internal/billing/v2/actions/sync/subscriptionToSyncParams.ts Adds version to the sync plan params so that billing sync pins the exact matched product version; tested by the new integration test.
server/tests/integration/billing/sync/sync-plan-version.test.ts New integration tests covering exact Stripe price → plan version selection for both single subscriptions and scheduled phases; comprehensive coverage of the sync version-pinning path.
server/tests/integration/licenses/catalog-update/license-catalog-response.test.ts Adds tests for fresh explicit versioning with pinned license dependencies and version-gap rejection; correctly exercises the new validateCatalogPlanVersionTargets path.
server/tests/unit/catalog/catalog-plan-dependencies.test.ts Updates expected sort order to ["child@1", "child@2", "parent@0"] after the previous-version traversal change, and adds a unit test for gap-version rejection.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/atmn/src/commands/push/push.ts:230-237
**`deletionCheckFailed` flag is never read — fail-closed intent is not enforced**

When `getPlanDeletionInfo` throws, the catch block returns `canDelete: false, customerCount: 0, deletionCheckFailed: true`. Because `customerCount === 0`, `createPlanDeletePrompt` routes to `createPlanDeleteNoCustomersPrompt`, which presents **"Delete permanently"** as the default option. Nothing in `usePush.ts` (or anywhere else) reads `deletionCheckFailed` to override this behaviour, so if the user accepts the default the push will attempt to delete a plan whose customer status is genuinely unknown. The same pattern appears in `checkFeatureDeleteInfo`: `reason: "deletion_check_failed"` falls through `createFeatureDeletePrompt` to `createFeatureDeleteNoDepsPrompt`, again defaulting to "Delete permanently" as though the feature has no dependencies. Both cases contradict the PR's stated "fail closed and defer deletion" goal. At minimum the prompt should default to "Skip (keep as is)" when `deletionCheckFailed` is true; alternatively the failed-check entries could be excluded from `plansToDelete` / `featuresToDelete` entirely and silently deferred.

### Issue 2 of 2
packages/atmn/src/commands/push/push.ts:192-194
Extra indentation on the `if` statement — it is left over from the refactor and the surrounding code is at a single-tab indent level.

```suggestion
	}

	if (response && response.totalCount > 0) {
```

Reviews (1): Last reviewed commit: "fix: keep catalog push alive when deleti..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@capy-ai

capy-ai Bot commented Jul 20, 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.

Comment on lines +230 to 237
} catch {
return {
id: planId,
canDelete: false,
customerCount: response.totalCount,
firstCustomerName: response.customerName,
customerCount: 0,
deletionCheckFailed: true,
};
}

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 deletionCheckFailed flag is never read — fail-closed intent is not enforced

When getPlanDeletionInfo throws, the catch block returns canDelete: false, customerCount: 0, deletionCheckFailed: true. Because customerCount === 0, createPlanDeletePrompt routes to createPlanDeleteNoCustomersPrompt, which presents "Delete permanently" as the default option. Nothing in usePush.ts (or anywhere else) reads deletionCheckFailed to override this behaviour, so if the user accepts the default the push will attempt to delete a plan whose customer status is genuinely unknown. The same pattern appears in checkFeatureDeleteInfo: reason: "deletion_check_failed" falls through createFeatureDeletePrompt to createFeatureDeleteNoDepsPrompt, again defaulting to "Delete permanently" as though the feature has no dependencies. Both cases contradict the PR's stated "fail closed and defer deletion" goal. At minimum the prompt should default to "Skip (keep as is)" when deletionCheckFailed is true; alternatively the failed-check entries could be excluded from plansToDelete / featuresToDelete entirely and silently deferred.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/atmn/src/commands/push/push.ts
Line: 230-237

Comment:
**`deletionCheckFailed` flag is never read — fail-closed intent is not enforced**

When `getPlanDeletionInfo` throws, the catch block returns `canDelete: false, customerCount: 0, deletionCheckFailed: true`. Because `customerCount === 0`, `createPlanDeletePrompt` routes to `createPlanDeleteNoCustomersPrompt`, which presents **"Delete permanently"** as the default option. Nothing in `usePush.ts` (or anywhere else) reads `deletionCheckFailed` to override this behaviour, so if the user accepts the default the push will attempt to delete a plan whose customer status is genuinely unknown. The same pattern appears in `checkFeatureDeleteInfo`: `reason: "deletion_check_failed"` falls through `createFeatureDeletePrompt` to `createFeatureDeleteNoDepsPrompt`, again defaulting to "Delete permanently" as though the feature has no dependencies. Both cases contradict the PR's stated "fail closed and defer deletion" goal. At minimum the prompt should default to "Skip (keep as is)" when `deletionCheckFailed` is true; alternatively the failed-check entries could be excluded from `plansToDelete` / `featuresToDelete` entirely and silently deferred.

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

Comment on lines +192 to +194
}

if (response && response.totalCount > 0) {
if (response && response.totalCount > 0) {

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 Extra indentation on the if statement — it is left over from the refactor and the surrounding code is at a single-tab indent level.

Suggested change
}
if (response && response.totalCount > 0) {
if (response && response.totalCount > 0) {
}
if (response && response.totalCount > 0) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/atmn/src/commands/push/push.ts
Line: 192-194

Comment:
Extra indentation on the `if` statement — it is left over from the refactor and the surrounding code is at a single-tab indent level.

```suggestion
	}

	if (response && response.totalCount > 0) {
```

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!

@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.

3 issues found across 11 files

Confidence score: 2/5

  • In packages/atmn/src/commands/push/push.ts, the license transform path appears to drop customize/prepaidOnly and optional customize-license fields, which can rewrite omitted included semantics and send materially different license payloads than intended. Merging as-is risks silently changing plan configuration on push — preserve optional fields in the API↔SDK mappings and re-run normalization/compare against full license data before merging.
  • In server/src/internal/catalog/actions/updateCatalog/updateCatalog.ts, the recovery path passes variantUpdates without propagateToVariants, so selected variants may not be updated when update_variant_ids is present and the requested version is missing. Merging this path as-is can leave catalog variants out of sync after recovery — include propagateToVariants (or equivalent propagation behavior) in that call before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/src/internal/catalog/actions/updateCatalog/updateCatalog.ts">

<violation number="1" location="server/src/internal/catalog/actions/updateCatalog/updateCatalog.ts:183">
P2: Selected variants are not updated when this recovery path is taken: the call passes `variantUpdates` but omits `propagateToVariants`. When a catalog plan has `update_variant_ids` and its requested version is missing, the base product is recovered but the requested variant propagation is silently skipped.</violation>
</file>

<file name="packages/atmn/src/commands/push/push.ts">

<violation number="1" location="packages/atmn/src/commands/push/push.ts:590">
P2: License comparison does not actually observe the new `prepaidOnly` or `customize` values because the API-to-SDK transform drops both fields before `normalizePlanForCompare` runs. Updating the license model and transform alongside this comparison would prevent these lines from being dead and ensure plan changes are detected.</violation>

<violation number="2" location="packages/atmn/src/commands/push/push.ts:696">
P1: Variant license customization loses its optional fields and changes the meaning of omitted `included` values in every payload using this path. Mapping customize-license entries directly while preserving optional `included`, `prepaidOnly`, and nested `customize` fields would avoid sending `0` or dropping the requested customization.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

: {}),
...(customize.upsertLicenses !== undefined
? {
upsert_licenses: transformPlanToApi({

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: Variant license customization loses its optional fields and changes the meaning of omitted included values in every payload using this path. Mapping customize-license entries directly while preserving optional included, prepaidOnly, and nested customize fields would avoid sending 0 or dropping the requested customization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/atmn/src/commands/push/push.ts, line 696:

<comment>Variant license customization loses its optional fields and changes the meaning of omitted `included` values in every payload using this path. Mapping customize-license entries directly while preserving optional `included`, `prepaidOnly`, and nested `customize` fields would avoid sending `0` or dropping the requested customization.</comment>

<file context>
@@ -668,6 +691,15 @@ function toApiCustomizePlan(customize: CustomizePlan): Record<string, unknown> {
 			: {}),
+		...(customize.upsertLicenses !== undefined
+			? {
+					upsert_licenses: transformPlanToApi({
+						id: "license-customize",
+						name: "License customize",
</file context>

query: { force_version: true },
updates,
initialFullProduct: latest,
variantUpdates,

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: Selected variants are not updated when this recovery path is taken: the call passes variantUpdates but omits propagateToVariants. When a catalog plan has update_variant_ids and its requested version is missing, the base product is recovered but the requested variant propagation is silently skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/catalog/actions/updateCatalog/updateCatalog.ts, line 183:

<comment>Selected variants are not updated when this recovery path is taken: the call passes `variantUpdates` but omits `propagateToVariants`. When a catalog plan has `update_variant_ids` and its requested version is missing, the base product is recovered but the requested variant propagation is silently skipped.</comment>

<file context>
@@ -158,6 +158,32 @@ const upsertPlans = async ({
+					query: { force_version: true },
+					updates,
+					initialFullProduct: latest,
+					variantUpdates,
+				});
+				continue;
</file context>
Suggested change
variantUpdates,
variantUpdates,
propagateToVariants: update_variant_ids ?? [],

Comment thread server/src/internal/catalog/actions/catalogPlanDependencies.ts Outdated
version:
license.version ?? licenseVersionFallbacks?.get(license.licensePlanId),
included: license.included ?? 0,
prepaidOnly: license.prepaidOnly ?? true,

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: License comparison does not actually observe the new prepaidOnly or customize values because the API-to-SDK transform drops both fields before normalizePlanForCompare runs. Updating the license model and transform alongside this comparison would prevent these lines from being dead and ensure plan changes are detected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/atmn/src/commands/push/push.ts, line 590:

<comment>License comparison does not actually observe the new `prepaidOnly` or `customize` values because the API-to-SDK transform drops both fields before `normalizePlanForCompare` runs. Updating the license model and transform alongside this comparison would prevent these lines from being dead and ensure plan changes are detected.</comment>

<file context>
@@ -572,6 +587,8 @@ function normalizePlanForCompare(
 			version:
 				license.version ?? licenseVersionFallbacks?.get(license.licensePlanId),
 			included: license.included ?? 0,
+			prepaidOnly: license.prepaidOnly ?? true,
+			customize: license.customize,
 		}));
</file context>

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
checkout Ignored Ignored Jul 20, 2026 7:06pm
landing-page Ignored Ignored Jul 20, 2026 7:06pm

Request Review

@vercel
vercel Bot temporarily deployed to Preview – autumn-vite July 20, 2026 19:06 Inactive
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.

1 participant