Skip to content

feat/cli: archive removed products#135

Open
Blackmamoth wants to merge 2 commits intogetpaykit:mainfrom
Blackmamoth:feat/cli-archive-removed-products
Open

feat/cli: archive removed products#135
Blackmamoth wants to merge 2 commits intogetpaykit:mainfrom
Blackmamoth:feat/cli-archive-removed-products

Conversation

@Blackmamoth
Copy link
Copy Markdown

@Blackmamoth Blackmamoth commented Apr 14, 2026

Implements #123

Summary by CodeRabbit

  • New Features

    • Products removed from your PayKit configuration are now archived instead of deleted, preserving historical subscription and webhook data.
    • CLI output now displays "archived" status when products are removed during paykitjs push.
    • Archived Stripe products are automatically marked inactive on the provider side.
  • Documentation

    • Updated CLI, database, and installation guides to clarify product archival behavior.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

@Blackmamoth is attempting to deploy a commit to the maxktz Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

📝 Walkthrough

Walkthrough

This PR introduces product archiving functionality to PayKit. When products are removed from the configuration during paykitjs push, they are now archived in the database instead of deleted, preserving historical subscription and webhook data. The archived status is reflected in Stripe by marking products as inactive. Changes include database schema updates, service layer implementations, provider interface extensions, CLI tooling updates, and comprehensive test coverage.

Changes

Cohort / File(s) Summary
Changeset & Documentation
.changeset/archive-removed-products.md, landing/content/docs/concepts/cli.mdx, landing/content/docs/concepts/database.mdx, landing/content/docs/get-started/installation.mdx
Added release notes and updated docs to clarify that removed products are archived rather than deleted, preserving historical data and webhook resolution.
Database Schema & Migrations
packages/paykit/src/database/migrations/0001_add_product_archived_at.sql, packages/paykit/src/database/migrations/meta/0001_snapshot.json, packages/paykit/src/database/migrations/meta/_journal.json, packages/paykit/src/database/schema.ts
Added archivedAt timestamp column to paykit_product table with corresponding index, and updated schema definition.
Product Service Core
packages/paykit/src/product/product.service.ts
Extended product queries to support archiving: added includeArchived parameter, new listLatestActiveProducts, restoreProduct, and archiveProductsByIds functions; updated existing queries to exclude archived products by default.
Product Sync Service
packages/paykit/src/product/product-sync.service.ts
Implemented archiving logic in dry-run and sync flows: detects removed products, archives them locally and in providers, supports restoration of archived products when reintroduced, and returns detailed result metadata including name and pricing.
Provider Interface & Stripe
packages/paykit/src/providers/provider.ts, packages/stripe/src/stripe-provider.ts
Added archiveProduct method to PaymentProvider interface; implemented Stripe archiving via products.update({ active: false }) and updated syncProduct to reactivate products with active: true.
CLI & Formatting
packages/paykit/src/cli/utils/format.ts, packages/paykit/src/cli/utils/shared.ts, e2e/cli/setup.ts
Extended ProductDiff to support "archived" action and include pricing metadata; updated formatting to display archived products in red; added writeConfig helper to test fixture for config variations.
Test Files
e2e/cli/push.test.ts, packages/paykit/src/product/__tests__/product-sync.service.test.ts, packages/paykit/src/customer/__tests__/customer.service.test.ts, packages/stripe/src/__tests__/stripe-provider.test.ts, packages/stripe/src/__tests__/stripe.test.ts
Added E2E test verifying archiving workflow; new comprehensive test suite for product-sync.service covering archiving, restoration, and provider sync scenarios; added provider method assertions and Stripe archiving/reactivation tests.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI as paykitjs push
    participant SyncService as product-sync.service
    participant ProductService as product.service
    participant Database as Database
    participant Provider as Stripe Provider

    User->>CLI: Run push with removed product
    CLI->>SyncService: syncProducts(ctx)
    SyncService->>ProductService: listLatestActiveProducts()
    ProductService->>Database: Query active products
    Database-->>ProductService: [active products]
    ProductService-->>SyncService: [all active products]
    SyncService->>SyncService: Identify products in config<br/>vs. active products
    Note over SyncService: Products not in config<br/>need archiving
    SyncService->>ProductService: archiveProductsByIds(ids)
    ProductService->>Database: UPDATE archivedAt WHERE id IN (...)
    Database-->>ProductService: [archived products]
    ProductService-->>SyncService: [archived products]
    SyncService->>Provider: archiveProduct({ providerProductId })
    Provider->>Provider: Stripe: products.update<br/>({ active: false })
    Provider-->>SyncService: void
    SyncService-->>CLI: [SyncResult { action: "archived", ... }]
    CLI-->>User: Display archived products
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A product removed from the config now rests,
Archived in peace, not deleted at best!
Stripe marks it inactive, the webhook still knows,
Historical data forever flows... 📦✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat/cli: archive removed products' directly describes the main change across the entire changeset—introducing product archival functionality for products removed from configuration during CLI push operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/stripe/src/__tests__/stripe-provider.test.ts (1)

17-30: Consider a behavior assertion for archiveProduct, not just existence.

Type checks can still pass if implementation becomes a no-op. A mocked SDK expectation would make this regression-proof.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stripe/src/__tests__/stripe-provider.test.ts` around lines 17 - 30,
Test currently only checks that archiveProduct exists on the adapter which won't
catch regressions where it becomes a no-op; update the test that constructs the
adapter via stripe({ secretKey..., webhookSecret... }).createAdapter() to assert
archiveProduct performs the expected behavior by calling adapter.archiveProduct
with a sample product id and using a mocked Stripe SDK to verify the correct SDK
method was invoked (e.g.,
expect(stripeMock.products.del).toHaveBeenCalledWith(productId) or whichever SDK
call your implementation uses), instead of only checking typeof archiveProduct.
packages/paykit/src/product/product-sync.service.ts (2)

171-178: Consider handling restoreProduct returning null explicitly.

The restoreProduct function can return null if no rows are updated. While the error check at lines 181-187 catches this, the generic error message may obscure the actual cause.

💡 Suggested improvement for clearer error handling
     } else if (existing.product.name !== plan.name || existing.product.archivedAt) {
       if (existing.product.name !== plan.name) {
         await updateProductName(ctx.database, existing.product.internalId, plan.name);
       }
-      storedProduct = existing.product.archivedAt
-        ? await restoreProduct(ctx.database, existing.product.internalId)
-        : { ...existing.product, name: plan.name };
+      if (existing.product.archivedAt) {
+        const restored = await restoreProduct(ctx.database, existing.product.internalId);
+        if (!restored) {
+          throw PayKitError.from(
+            "INTERNAL_SERVER_ERROR",
+            PAYKIT_ERROR_CODES.PLAN_SYNC_FAILED,
+            `Failed to restore archived plan "${plan.id}"`,
+          );
+        }
+        storedProduct = restored;
+      } else {
+        storedProduct = { ...existing.product, name: plan.name };
+      }
       action = "updated";
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/product/product-sync.service.ts` around lines 171 - 178,
The restoreProduct call may return null when no rows are updated, but the code
currently proceeds and only later throws a generic error; update the branch in
product-sync.service.ts where restoreProduct(ctx.database,
existing.product.internalId) is called so you explicitly check its return value,
and if null throw a descriptive error (include existing.product.internalId and
plan.name) instead of letting a later generic check run; ensure storedProduct is
only set when restoreProduct returns a non-null product, and keep the existing
updateProductName(existing.product.internalId, plan.name) call as-is.

241-243: Provider archiving errors will abort the sync without returning partial results.

If archiveProduct throws for any product, the function exits without returning results for successfully synced plans. The database will already have archived the products, leaving a partial state.

This may be acceptable if the caller can retry, but consider whether you want to catch and log provider errors while continuing with other products.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/product/product-sync.service.ts` around lines 241 - 243,
The loop over archivedProviderProductIds calls ctx.provider.archiveProduct
which, if it throws, will abort the sync and prevent returning partial results;
update the loop in product-sync.service.ts to wrap each await
ctx.provider.archiveProduct({ providerProductId }) call in a try/catch, log the
error with the providerProductId and any error details (using the existing
logger/context), continue processing remaining providerProductIds, and
optionally collect failedProviderProductIds into an array to include in the
function's return value so callers can see which archives failed while others
succeeded.
packages/paykit/src/product/product.service.ts (1)

96-112: Implementation is correct; performance note for scale.

The logic correctly retrieves the latest version per product ID. For large product catalogs with many versions, fetching all rows could be inefficient. Consider using DISTINCT ON (id) (PostgreSQL) or a subquery for better database-level deduplication if this becomes a hot path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/product/product.service.ts` around lines 96 - 112, The
current listLatestActiveProducts function fetches all active product rows into
rows and de-duplicates in memory via productsById, which will be inefficient at
scale; change the query to let the database return only the latest version per
product id (e.g., use a DISTINCT ON (id) clause or a subquery that selects
max(version) per id) instead of post-filtering rows, so modify the call in
listLatestActiveProducts (the database.query.product.findMany usage) to perform
DB-level deduplication and then remove the in-memory loop over
rows/productsById.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/paykit/src/cli/utils/shared.ts`:
- Around line 74-76: The CLI is still passing diff.id to formatPlanLine so the
new product name field is never used; update the calls at/around lines where
formatPlanLine is invoked (e.g., the call that currently passes diff.id) to pass
diff.name instead, and if necessary adjust the formatPlanLine signature/usage to
accept and display a name (function name: formatPlanLine) so diff.name is
rendered in the diff output rather than the ID.

---

Nitpick comments:
In `@packages/paykit/src/product/product-sync.service.ts`:
- Around line 171-178: The restoreProduct call may return null when no rows are
updated, but the code currently proceeds and only later throws a generic error;
update the branch in product-sync.service.ts where restoreProduct(ctx.database,
existing.product.internalId) is called so you explicitly check its return value,
and if null throw a descriptive error (include existing.product.internalId and
plan.name) instead of letting a later generic check run; ensure storedProduct is
only set when restoreProduct returns a non-null product, and keep the existing
updateProductName(existing.product.internalId, plan.name) call as-is.
- Around line 241-243: The loop over archivedProviderProductIds calls
ctx.provider.archiveProduct which, if it throws, will abort the sync and prevent
returning partial results; update the loop in product-sync.service.ts to wrap
each await ctx.provider.archiveProduct({ providerProductId }) call in a
try/catch, log the error with the providerProductId and any error details (using
the existing logger/context), continue processing remaining providerProductIds,
and optionally collect failedProviderProductIds into an array to include in the
function's return value so callers can see which archives failed while others
succeeded.

In `@packages/paykit/src/product/product.service.ts`:
- Around line 96-112: The current listLatestActiveProducts function fetches all
active product rows into rows and de-duplicates in memory via productsById,
which will be inefficient at scale; change the query to let the database return
only the latest version per product id (e.g., use a DISTINCT ON (id) clause or a
subquery that selects max(version) per id) instead of post-filtering rows, so
modify the call in listLatestActiveProducts (the database.query.product.findMany
usage) to perform DB-level deduplication and then remove the in-memory loop over
rows/productsById.

In `@packages/stripe/src/__tests__/stripe-provider.test.ts`:
- Around line 17-30: Test currently only checks that archiveProduct exists on
the adapter which won't catch regressions where it becomes a no-op; update the
test that constructs the adapter via stripe({ secretKey..., webhookSecret...
}).createAdapter() to assert archiveProduct performs the expected behavior by
calling adapter.archiveProduct with a sample product id and using a mocked
Stripe SDK to verify the correct SDK method was invoked (e.g.,
expect(stripeMock.products.del).toHaveBeenCalledWith(productId) or whichever SDK
call your implementation uses), instead of only checking typeof archiveProduct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: dfb37da1-f418-428d-86bf-2efebf6d2986

📥 Commits

Reviewing files that changed from the base of the PR and between f044b35 and 80e490c.

📒 Files selected for processing (20)
  • .changeset/archive-removed-products.md
  • e2e/cli/push.test.ts
  • e2e/cli/setup.ts
  • landing/content/docs/concepts/cli.mdx
  • landing/content/docs/concepts/database.mdx
  • landing/content/docs/get-started/installation.mdx
  • packages/paykit/src/cli/utils/format.ts
  • packages/paykit/src/cli/utils/shared.ts
  • packages/paykit/src/customer/__tests__/customer.service.test.ts
  • packages/paykit/src/database/migrations/0001_add_product_archived_at.sql
  • packages/paykit/src/database/migrations/meta/0001_snapshot.json
  • packages/paykit/src/database/migrations/meta/_journal.json
  • packages/paykit/src/database/schema.ts
  • packages/paykit/src/product/__tests__/product-sync.service.test.ts
  • packages/paykit/src/product/product-sync.service.ts
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/providers/provider.ts
  • packages/stripe/src/__tests__/stripe-provider.test.ts
  • packages/stripe/src/__tests__/stripe.test.ts
  • packages/stripe/src/stripe-provider.ts

Comment on lines +74 to +76
name: string;
priceAmount: number | null;
priceInterval: string | null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use product name (not ID) when formatting diff lines.

Line 90 still passes diff.id to formatPlanLine, so the newly added name field (Line 74) is ignored in CLI output.

Proposed fix
 export function formatProductDiffs(
   diffs: ProductDiff[],
   plans: readonly NormalizedPlan[],
   deps: Pick<CliDeps, "formatPlanLine" | "formatPrice">,
 ): string[] {
   const plansById = new Map(plans.map((pl) => [pl.id, pl]));
   return diffs.map((diff) => {
     const plan = plansById.get(diff.id);
     const amount = plan?.priceAmount ?? diff.priceAmount ?? 0;
     const interval = plan?.priceInterval ?? diff.priceInterval;
     const price = deps.formatPrice(amount, interval);
-    return deps.formatPlanLine(diff.action, diff.id, price);
+    const displayName = plan?.name ?? diff.name;
+    return deps.formatPlanLine(diff.action, displayName, price);
   });
 }

Also applies to: 87-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/cli/utils/shared.ts` around lines 74 - 76, The CLI is
still passing diff.id to formatPlanLine so the new product name field is never
used; update the calls at/around lines where formatPlanLine is invoked (e.g.,
the call that currently passes diff.id) to pass diff.name instead, and if
necessary adjust the formatPlanLine signature/usage to accept and display a name
(function name: formatPlanLine) so diff.name is rendered in the diff output
rather than the ID.

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