From 6457e85e5ebdd04407f8c8b10f5481e567c4448b Mon Sep 17 00:00:00 2001 From: Blackmamoth Date: Tue, 14 Apr 2026 15:35:54 +0530 Subject: [PATCH 1/2] feat(CLI): make push aware of product removals --- .changeset/archive-removed-products.md | 6 + e2e/cli/push.test.ts | 43 + e2e/cli/setup.ts | 18 +- packages/paykit/src/cli/utils/format.ts | 4 +- packages/paykit/src/cli/utils/shared.ts | 9 +- .../__tests__/customer.service.test.ts | 2 + .../0001_add_product_archived_at.sql | 2 + .../migrations/meta/0001_snapshot.json | 1243 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/paykit/src/database/schema.ts | 2 + .../__tests__/product-sync.service.test.ts | 213 +++ .../src/product/product-sync.service.ts | 86 +- .../paykit/src/product/product.service.ts | 70 +- packages/paykit/src/providers/provider.ts | 2 + .../src/__tests__/stripe-provider.test.ts | 1 + packages/stripe/src/__tests__/stripe.test.ts | 44 + packages/stripe/src/stripe-provider.ts | 6 +- 17 files changed, 1735 insertions(+), 23 deletions(-) create mode 100644 .changeset/archive-removed-products.md create mode 100644 packages/paykit/src/database/migrations/0001_add_product_archived_at.sql create mode 100644 packages/paykit/src/database/migrations/meta/0001_snapshot.json create mode 100644 packages/paykit/src/product/__tests__/product-sync.service.test.ts diff --git a/.changeset/archive-removed-products.md b/.changeset/archive-removed-products.md new file mode 100644 index 00000000..74c5206e --- /dev/null +++ b/.changeset/archive-removed-products.md @@ -0,0 +1,6 @@ +--- +"paykitjs": minor +"@paykitjs/stripe": minor +--- + +Archive products removed from PayKit config during push and mark Stripe products inactive. diff --git a/e2e/cli/push.test.ts b/e2e/cli/push.test.ts index 66fd9828..54c17640 100644 --- a/e2e/cli/push.test.ts +++ b/e2e/cli/push.test.ts @@ -118,4 +118,47 @@ describe("paykitjs push", () => { await database.end(); } }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("should archive products removed from config", async () => { + await fixture.writeConfig({ includePro: false }); + + const config = await getPayKitConfig({ cwd: fixture.cwd }); + const database = resolveDatabase(config.options.database); + try { + const ctx = await createContext({ ...config.options, database }); + const diffs = await dryRunSyncProducts(ctx); + expect(diffs).toContainEqual(expect.objectContaining({ action: "archived", id: "pro" })); + + const providerRows = await ctx.database + .select({ provider: product.provider }) + .from(product) + .where(eq(product.id, "pro")) + .orderBy(desc(product.version)) + .limit(1); + const proProduct = providerRows[0] as + | { provider: Record } + | undefined; + const stripeInfo = proProduct?.provider.stripe; + if (!stripeInfo) { + throw new Error("Missing Stripe product metadata for synced plan"); + } + + const results = await syncProducts(ctx); + expect(results).toContainEqual(expect.objectContaining({ action: "archived", id: "pro" })); + + const archivedRows = await ctx.database + .select({ archivedAt: product.archivedAt }) + .from(product) + .where(eq(product.id, "pro")) + .orderBy(desc(product.version)) + .limit(1); + expect(archivedRows[0]?.archivedAt).toBeInstanceOf(Date); + + const stripeProduct = await fixture.stripeClient.products.retrieve(stripeInfo.productId); + expect(stripeProduct.active).toBe(false); + } finally { + await database.end(); + } + }); }); diff --git a/e2e/cli/setup.ts b/e2e/cli/setup.ts index 73fcb705..1e7f9e1c 100644 --- a/e2e/cli/setup.ts +++ b/e2e/cli/setup.ts @@ -21,6 +21,7 @@ export interface CliTestFixture { dbName: string; dbUrl: string; stripeClient: Stripe; + writeConfig: (options: { includePro: boolean }) => Promise; cleanup: () => Promise; } @@ -55,9 +56,8 @@ export async function createCliFixture(_globalKey: string): Promise { + const lines = [ `import { createPayKit, feature, plan } from ${JSON.stringify(toImportPath(createPayKitPath))};`, `import { stripe } from ${JSON.stringify(toImportPath(stripePath))};`, `import pg from "pg";`, @@ -88,10 +88,14 @@ export async function createCliFixture(_globalKey: string): Promise { // Clean up Stripe products created by push @@ -123,5 +127,5 @@ export async function createCliFixture(_globalKey: string): Promise, database: Pool | string) } export interface ProductDiff { - action: "created" | "updated" | "unchanged"; + action: "archived" | "created" | "updated" | "unchanged"; id: string; + name: string; + priceAmount: number | null; + priceInterval: string | null; } export function formatProductDiffs( @@ -81,7 +84,9 @@ export function formatProductDiffs( const plansById = new Map(plans.map((pl) => [pl.id, pl])); return diffs.map((diff) => { const plan = plansById.get(diff.id); - const price = plan ? deps.formatPrice(plan.priceAmount ?? 0, plan.priceInterval) : "$0"; + 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); }); } diff --git a/packages/paykit/src/customer/__tests__/customer.service.test.ts b/packages/paykit/src/customer/__tests__/customer.service.test.ts index 92136450..832d09a5 100644 --- a/packages/paykit/src/customer/__tests__/customer.service.test.ts +++ b/packages/paykit/src/customer/__tests__/customer.service.test.ts @@ -63,6 +63,7 @@ describe("customer/service", () => { .mockResolvedValueOnce(syncedCustomer); const stripe = { advanceTestClock: vi.fn(), + archiveProduct: vi.fn(), attachPaymentMethod: vi.fn(), cancelSubscription: vi.fn(), createInvoice: vi.fn(), @@ -160,6 +161,7 @@ describe("customer/service", () => { .mockResolvedValueOnce(syncedCustomer); const stripe = { advanceTestClock: vi.fn(), + archiveProduct: vi.fn(), attachPaymentMethod: vi.fn(), cancelSubscription: vi.fn(), createInvoice: vi.fn(), diff --git a/packages/paykit/src/database/migrations/0001_add_product_archived_at.sql b/packages/paykit/src/database/migrations/0001_add_product_archived_at.sql new file mode 100644 index 00000000..5fb501b0 --- /dev/null +++ b/packages/paykit/src/database/migrations/0001_add_product_archived_at.sql @@ -0,0 +1,2 @@ +ALTER TABLE "paykit_product" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +CREATE INDEX "paykit_product_archived_at_idx" ON "paykit_product" USING btree ("archived_at"); \ No newline at end of file diff --git a/packages/paykit/src/database/migrations/meta/0001_snapshot.json b/packages/paykit/src/database/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..4bf9bd84 --- /dev/null +++ b/packages/paykit/src/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,1243 @@ +{ + "id": "0971db2a-62e1-427b-bd48-9a102e74842c", + "prevId": "476e3c1e-e8b7-4c5b-9d43-f10c4173b9ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.paykit_customer": { + "name": "paykit_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_customer_deleted_at_idx": { + "name": "paykit_customer_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_entitlement": { + "name": "paykit_entitlement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_reset_at": { + "name": "next_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_entitlement_subscription_idx": { + "name": "paykit_entitlement_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_entitlement_customer_feature_idx": { + "name": "paykit_entitlement_customer_feature_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_entitlement_next_reset_idx": { + "name": "paykit_entitlement_next_reset_idx", + "columns": [ + { + "expression": "next_reset_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_entitlement_subscription_id_paykit_subscription_id_fk": { + "name": "paykit_entitlement_subscription_id_paykit_subscription_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_entitlement_customer_id_paykit_customer_id_fk": { + "name": "paykit_entitlement_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_entitlement_feature_id_paykit_feature_id_fk": { + "name": "paykit_entitlement_feature_id_paykit_feature_id_fk", + "tableFrom": "paykit_entitlement", + "tableTo": "paykit_feature", + "columnsFrom": [ + "feature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_feature": { + "name": "paykit_feature", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_invoice": { + "name": "paykit_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_url": { + "name": "hosted_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_data": { + "name": "provider_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "period_start_at": { + "name": "period_start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end_at": { + "name": "period_end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_invoice_customer_idx": { + "name": "paykit_invoice_customer_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_invoice_subscription_idx": { + "name": "paykit_invoice_subscription_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_invoice_provider_idx": { + "name": "paykit_invoice_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_invoice_customer_id_paykit_customer_id_fk": { + "name": "paykit_invoice_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_invoice", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_invoice_subscription_id_paykit_subscription_id_fk": { + "name": "paykit_invoice_subscription_id_paykit_subscription_id_fk", + "tableFrom": "paykit_invoice", + "tableTo": "paykit_subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_metadata": { + "name": "paykit_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_metadata_checkout_session_unique": { + "name": "paykit_metadata_checkout_session_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_payment_method": { + "name": "paykit_payment_method", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_data": { + "name": "provider_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_payment_method_customer_idx": { + "name": "paykit_payment_method_customer_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_payment_method_provider_idx": { + "name": "paykit_payment_method_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_payment_method_customer_id_paykit_customer_id_fk": { + "name": "paykit_payment_method_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_payment_method", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_product": { + "name": "paykit_product", + "schema": "", + "columns": { + "internal_id": { + "name": "internal_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group": { + "name": "group", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_amount": { + "name": "price_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price_interval": { + "name": "price_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_product_id_version_unique": { + "name": "paykit_product_id_version_unique", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_product_default_idx": { + "name": "paykit_product_default_idx", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_product_archived_at_idx": { + "name": "paykit_product_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_product_feature": { + "name": "paykit_product_feature", + "schema": "", + "columns": { + "product_internal_id": { + "name": "product_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reset_interval": { + "name": "reset_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_product_feature_feature_idx": { + "name": "paykit_product_feature_feature_idx", + "columns": [ + { + "expression": "feature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk": { + "name": "paykit_product_feature_product_internal_id_paykit_product_internal_id_fk", + "tableFrom": "paykit_product_feature", + "tableTo": "paykit_product", + "columnsFrom": [ + "product_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_product_feature_feature_id_paykit_feature_id_fk": { + "name": "paykit_product_feature_feature_id_paykit_feature_id_fk", + "tableFrom": "paykit_product_feature", + "tableTo": "paykit_feature", + "columnsFrom": [ + "feature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paykit_product_feature_product_internal_id_feature_id_pk": { + "name": "paykit_product_feature_product_internal_id_feature_id_pk", + "columns": [ + "product_internal_id", + "feature_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_subscription": { + "name": "paykit_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_internal_id": { + "name": "product_internal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_data": { + "name": "provider_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canceled": { + "name": "canceled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_start_at": { + "name": "current_period_start_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end_at": { + "name": "current_period_end_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scheduled_product_id": { + "name": "scheduled_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "paykit_subscription_customer_status_idx": { + "name": "paykit_subscription_customer_status_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_subscription_product_idx": { + "name": "paykit_subscription_product_idx", + "columns": [ + { + "expression": "product_internal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_subscription_provider_idx": { + "name": "paykit_subscription_provider_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paykit_subscription_customer_id_paykit_customer_id_fk": { + "name": "paykit_subscription_customer_id_paykit_customer_id_fk", + "tableFrom": "paykit_subscription", + "tableTo": "paykit_customer", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "paykit_subscription_product_internal_id_paykit_product_internal_id_fk": { + "name": "paykit_subscription_product_internal_id_paykit_product_internal_id_fk", + "tableFrom": "paykit_subscription", + "tableTo": "paykit_product", + "columnsFrom": [ + "product_internal_id" + ], + "columnsTo": [ + "internal_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paykit_webhook_event": { + "name": "paykit_webhook_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paykit_webhook_event_provider_unique": { + "name": "paykit_webhook_event_provider_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paykit_webhook_event_status_idx": { + "name": "paykit_webhook_event_status_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/paykit/src/database/migrations/meta/_journal.json b/packages/paykit/src/database/migrations/meta/_journal.json index ca85e22c..fe2ee126 100644 --- a/packages/paykit/src/database/migrations/meta/_journal.json +++ b/packages/paykit/src/database/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1775526333776, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1776154939173, + "tag": "0001_add_product_archived_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/paykit/src/database/schema.ts b/packages/paykit/src/database/schema.ts index d26c530b..8cdf1b0c 100644 --- a/packages/paykit/src/database/schema.ts +++ b/packages/paykit/src/database/schema.ts @@ -79,12 +79,14 @@ export const product = pgTable( priceInterval: text("price_interval"), hash: text("hash"), provider: jsonb("provider").$type().notNull().default({}), + archivedAt: timestamp("archived_at"), createdAt, updatedAt, }, (table) => [ uniqueIndex("paykit_product_id_version_unique").on(table.id, table.version), index("paykit_product_default_idx").on(table.isDefault), + index("paykit_product_archived_at_idx").on(table.archivedAt), ], ); diff --git a/packages/paykit/src/product/__tests__/product-sync.service.test.ts b/packages/paykit/src/product/__tests__/product-sync.service.test.ts new file mode 100644 index 00000000..b96c8537 --- /dev/null +++ b/packages/paykit/src/product/__tests__/product-sync.service.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PayKitContext } from "../../core/context"; +import type { StoredProduct, StoredProductFeature } from "../../types/models"; +import type { NormalizedPlan } from "../../types/schema"; +import { dryRunSyncProducts, syncProducts } from "../product-sync.service"; + +const service = vi.hoisted(() => ({ + archiveProductsByIds: vi.fn(), + getLatestProductSnapshot: vi.fn(), + getProviderProduct: vi.fn(), + insertProductVersion: vi.fn(), + listLatestActiveProducts: vi.fn(), + replaceProductFeatures: vi.fn(), + restoreProduct: vi.fn(), + updateProductName: vi.fn(), + upsertFeature: vi.fn(), + upsertProviderProduct: vi.fn(), +})); + +vi.mock("../product.service", () => service); + +function createPlan(overrides: Partial = {}): NormalizedPlan { + return { + group: "base", + hash: "hash_pro", + id: "pro", + includes: [], + isDefault: false, + name: "Pro", + priceAmount: 2_000, + priceInterval: "month", + trialDays: null, + ...overrides, + }; +} + +function createProduct(overrides: Partial = {}): StoredProduct { + const now = new Date("2024-01-01T00:00:00.000Z"); + + return { + archivedAt: null, + createdAt: now, + group: "base", + hash: "hash_pro", + id: "pro", + internalId: "prod_internal_123", + isDefault: false, + name: "Pro", + priceAmount: 2_000, + priceInterval: "month", + provider: {}, + updatedAt: now, + version: 1, + ...overrides, + }; +} + +function createContext(plans: readonly NormalizedPlan[] = []): PayKitContext { + return { + database: {}, + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + options: {}, + plans: { + features: [], + planMap: new Map(plans.map((plan) => [plan.id, plan])), + plans, + }, + provider: { + archiveProduct: vi.fn(), + id: "stripe", + name: "Stripe", + syncProduct: vi + .fn() + .mockResolvedValue({ providerPriceId: "price_123", providerProductId: "prod_123" }), + }, + } as unknown as PayKitContext; +} + +describe("product/product-sync.service", () => { + beforeEach(() => { + vi.clearAllMocks(); + service.getLatestProductSnapshot.mockResolvedValue(null); + service.getProviderProduct.mockResolvedValue(null); + service.insertProductVersion.mockImplementation(async (_database, input) => + createProduct({ + group: input.group, + hash: input.hash, + id: input.id, + isDefault: input.isDefault, + name: input.name, + priceAmount: input.priceAmount, + priceInterval: input.priceInterval, + version: input.version, + }), + ); + service.listLatestActiveProducts.mockResolvedValue([]); + service.archiveProductsByIds.mockResolvedValue([]); + service.restoreProduct.mockImplementation(async (_database, internalId) => + createProduct({ internalId }), + ); + }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("reports active database products missing from config as archived", async () => { + const free = createPlan({ + hash: "hash_free", + id: "free", + isDefault: true, + name: "Free", + priceAmount: null, + priceInterval: null, + }); + service.getLatestProductSnapshot.mockResolvedValue({ + features: [] satisfies readonly StoredProductFeature[], + product: createProduct({ + hash: "hash_free", + id: "free", + isDefault: true, + name: "Free", + priceAmount: null, + priceInterval: null, + }), + }); + service.listLatestActiveProducts.mockResolvedValue([createProduct()]); + + const results = await dryRunSyncProducts(createContext([free])); + + expect(results).toEqual([ + expect.objectContaining({ action: "unchanged", id: "free" }), + expect.objectContaining({ action: "archived", id: "pro", priceAmount: 2_000 }), + ]); + }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("archives removed paid products locally and in the provider", async () => { + const storedProduct = createProduct({ + provider: { stripe: { priceId: "price_123", productId: "prod_123" } }, + }); + const ctx = createContext([]); + service.listLatestActiveProducts.mockResolvedValue([storedProduct]); + service.archiveProductsByIds.mockResolvedValue([storedProduct]); + + const results = await syncProducts(ctx); + + expect(service.archiveProductsByIds).toHaveBeenCalledWith(ctx.database, ["pro"]); + expect(ctx.provider.archiveProduct).toHaveBeenCalledWith({ providerProductId: "prod_123" }); + expect(results).toEqual([ + expect.objectContaining({ action: "archived", id: "pro", version: 1 }), + ]); + }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("restores a reintroduced archived product when the definition is unchanged", async () => { + const plan = createPlan(); + const archivedProduct = createProduct({ archivedAt: new Date("2024-02-01T00:00:00.000Z") }); + const restoredProduct = createProduct(); + const ctx = createContext([plan]); + service.getLatestProductSnapshot.mockResolvedValue({ + features: [] satisfies readonly StoredProductFeature[], + product: archivedProduct, + }); + service.getProviderProduct.mockResolvedValue({ priceId: "price_123", productId: "prod_123" }); + service.restoreProduct.mockResolvedValue(restoredProduct); + + const results = await syncProducts(ctx); + + expect(service.restoreProduct).toHaveBeenCalledWith(ctx.database, archivedProduct.internalId); + expect(ctx.provider.syncProduct).toHaveBeenCalledWith({ + existingProviderPriceId: "price_123", + existingProviderProductId: "prod_123", + id: "pro", + name: "Pro", + priceAmount: 2_000, + priceInterval: "month", + }); + expect(results).toEqual([expect.objectContaining({ action: "updated", id: "pro" })]); + }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("creates a new version when a reintroduced archived product definition changed", async () => { + const plan = createPlan({ hash: "hash_pro_v2", priceAmount: 3_000 }); + const archivedProduct = createProduct({ archivedAt: new Date("2024-02-01T00:00:00.000Z") }); + const nextProduct = createProduct({ + hash: "hash_pro_v2", + priceAmount: 3_000, + version: 2, + }); + const ctx = createContext([plan]); + service.getLatestProductSnapshot.mockResolvedValue({ + features: [] satisfies readonly StoredProductFeature[], + product: archivedProduct, + }); + service.getProviderProduct.mockResolvedValue({ priceId: "price_123", productId: "prod_123" }); + service.insertProductVersion.mockResolvedValue(nextProduct); + + const results = await syncProducts(ctx); + + expect(service.insertProductVersion).toHaveBeenCalledWith( + ctx.database, + expect.objectContaining({ id: "pro", priceAmount: 3_000, version: 2 }), + ); + expect(ctx.provider.syncProduct).toHaveBeenCalledWith({ + existingProviderPriceId: null, + existingProviderProductId: "prod_123", + id: "pro", + name: "Pro", + priceAmount: 3_000, + priceInterval: "month", + }); + expect(results).toEqual([expect.objectContaining({ action: "created", version: 2 })]); + }); +}); diff --git a/packages/paykit/src/product/product-sync.service.ts b/packages/paykit/src/product/product-sync.service.ts index a43ad0f6..461117ac 100644 --- a/packages/paykit/src/product/product-sync.service.ts +++ b/packages/paykit/src/product/product-sync.service.ts @@ -3,19 +3,25 @@ import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import type { StoredProductFeature } from "../types/models"; import type { NormalizedPlan, NormalizedPlanFeature } from "../types/schema"; import { + archiveProductsByIds, getLatestProductSnapshot, getProviderProduct, insertProductVersion, + listLatestActiveProducts, replaceProductFeatures, + restoreProduct, updateProductName, upsertFeature, upsertProviderProduct, } from "./product.service"; export interface SyncProductResult { + action: "archived" | "created" | "updated" | "unchanged"; id: string; + name: string; + priceAmount: number | null; + priceInterval: string | null; version: number; - action: "created" | "updated" | "unchanged"; } function serializeFeatureConfig(config: Record | null): string { @@ -64,15 +70,20 @@ function planChanged( export async function dryRunSyncProducts(ctx: PayKitContext): Promise { const results: SyncProductResult[] = []; + const planIds = new Set(ctx.plans.plans.map((plan) => plan.id)); for (const plan of ctx.plans.plans) { - const existing = await getLatestProductSnapshot(ctx.database, plan.id); + const existing = await getLatestProductSnapshot(ctx.database, plan.id, { + includeArchived: true, + }); let action: SyncProductResult["action"] = "unchanged"; if (!existing) { action = "created"; } else if (planChanged(existing, plan)) { action = "created"; + } else if (existing.product.archivedAt) { + action = "updated"; } else if (existing.product.name !== plan.name) { action = "updated"; } @@ -80,10 +91,29 @@ export async function dryRunSyncProducts(ctx: PayKitContext): Promise plan.id)); + const activeProducts = await listLatestActiveProducts(ctx.database); + const productsToArchive = activeProducts.filter( + (storedProduct) => !planIds.has(storedProduct.id), + ); + const productIdsToArchive = productsToArchive.map((storedProduct) => storedProduct.id); + const archivedProducts = await archiveProductsByIds(ctx.database, productIdsToArchive); + const archivedProviderProductIds = new Set(); + + for (const storedProduct of archivedProducts) { + const providerMap = (storedProduct.provider ?? {}) as Record< + string, + { priceId: string | null; productId: string } + >; + const providerProductId = providerMap[providerId]?.productId; + if (providerProductId) { + archivedProviderProductIds.add(providerProductId); + } + } + + for (const providerProductId of archivedProviderProductIds) { + await ctx.provider.archiveProduct({ providerProductId }); + } + + for (const storedProduct of archivedProducts) { + results.push({ + action: "archived", + id: storedProduct.id, + name: storedProduct.name, + priceAmount: storedProduct.priceAmount, + priceInterval: storedProduct.priceInterval, version: storedProduct.version, }); } diff --git a/packages/paykit/src/product/product.service.ts b/packages/paykit/src/product/product.service.ts index 66f66f95..5c6165f4 100644 --- a/packages/paykit/src/product/product.service.ts +++ b/packages/paykit/src/product/product.service.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import { PayKitError, PAYKIT_ERROR_CODES } from "../core/errors"; import { generateId } from "../core/utils"; @@ -81,22 +81,43 @@ export async function upsertFeature( export async function getLatestProduct( database: PayKitDatabase, id: string, + options: { includeArchived?: boolean } = {}, ): Promise { const result = await database.query.product.findFirst({ - where: eq(product.id, id), + where: options.includeArchived + ? eq(product.id, id) + : and(eq(product.id, id), isNull(product.archivedAt)), orderBy: (p, { desc }) => [desc(p.version)], }); return result ?? null; } +export async function listLatestActiveProducts( + database: PayKitDatabase, +): Promise { + const rows = await database.query.product.findMany({ + where: isNull(product.archivedAt), + orderBy: (p, { desc }) => [desc(p.version)], + }); + const productsById = new Map(); + + for (const row of rows) { + if (!productsById.has(row.id)) { + productsById.set(row.id, row); + } + } + + return [...productsById.values()]; +} + export async function getProductByHash( database: PayKitDatabase, id: string, hash: string, ): Promise { const result = await database.query.product.findFirst({ - where: and(eq(product.id, id), eq(product.hash, hash)), + where: and(eq(product.id, id), eq(product.hash, hash), isNull(product.archivedAt)), orderBy: (p, { desc }) => [desc(p.version)], }); @@ -117,8 +138,9 @@ export async function getProductByInternalId( export async function getLatestProductSnapshot( database: PayKitDatabase, id: string, + options: { includeArchived?: boolean } = {}, ): Promise { - const storedProduct = await getLatestProduct(database, id); + const storedProduct = await getLatestProduct(database, id, options); if (!storedProduct) { return null; } @@ -156,11 +178,13 @@ export async function insertProductVersion( name: input.name, priceAmount: input.priceAmount, priceInterval: input.priceInterval, + archivedAt: null, updatedAt: now, version: input.version, }); return { + archivedAt: null, createdAt: now, group: input.group, hash: input.hash, @@ -187,6 +211,37 @@ export async function updateProductName( .where(eq(product.internalId, internalId)); } +export async function restoreProduct( + database: PayKitDatabase, + internalId: string, +): Promise { + const rows = await database + .update(product) + .set({ archivedAt: null, updatedAt: new Date() }) + .where(eq(product.internalId, internalId)) + .returning(); + + return rows[0] ?? null; +} + +export async function archiveProductsByIds( + database: PayKitDatabase, + ids: readonly string[], +): Promise { + if (ids.length === 0) { + return []; + } + + const now = new Date(); + const rows = await database + .update(product) + .set({ archivedAt: now, updatedAt: now }) + .where(and(inArray(product.id, ids), isNull(product.archivedAt))) + .returning(); + + return rows; +} + export async function getProductFeatures( database: PayKitDatabase, productInternalId: string, @@ -276,7 +331,7 @@ export async function getDefaultProductInGroup( group: string, ): Promise { const row = await database.query.product.findFirst({ - where: and(eq(product.group, group), eq(product.isDefault, true)), + where: and(eq(product.group, group), eq(product.isDefault, true), isNull(product.archivedAt)), orderBy: [desc(product.version)], }); @@ -288,7 +343,10 @@ export async function getProductByProviderPriceId( input: { providerId: string; providerPriceId: string }, ): Promise { const row = await database.query.product.findFirst({ - where: sql`${product.provider}->${input.providerId}->>'priceId' = ${input.providerPriceId}`, + where: and( + sql`${product.provider}->${input.providerId}->>'priceId' = ${input.providerPriceId}`, + isNull(product.archivedAt), + ), }); return row ?? null; diff --git a/packages/paykit/src/providers/provider.ts b/packages/paykit/src/providers/provider.ts index ef8a2adf..1a350c2f 100644 --- a/packages/paykit/src/providers/provider.ts +++ b/packages/paykit/src/providers/provider.ts @@ -140,6 +140,8 @@ export interface PaymentProvider { detachPaymentMethod(data: { providerMethodId: string }): Promise; + archiveProduct(data: { providerProductId: string }): Promise; + syncProduct(data: { id: string; name: string; diff --git a/packages/stripe/src/__tests__/stripe-provider.test.ts b/packages/stripe/src/__tests__/stripe-provider.test.ts index 0ae26b87..d048262d 100644 --- a/packages/stripe/src/__tests__/stripe-provider.test.ts +++ b/packages/stripe/src/__tests__/stripe-provider.test.ts @@ -25,6 +25,7 @@ describe("@paykitjs/stripe", () => { expect(adapter.name).toBe("Stripe"); expect(typeof adapter.createCustomer).toBe("function"); expect(typeof adapter.updateCustomer).toBe("function"); + expect(typeof adapter.archiveProduct).toBe("function"); expect(typeof adapter.handleWebhook).toBe("function"); }); }); diff --git a/packages/stripe/src/__tests__/stripe.test.ts b/packages/stripe/src/__tests__/stripe.test.ts index abeed9ea..9a45273c 100644 --- a/packages/stripe/src/__tests__/stripe.test.ts +++ b/packages/stripe/src/__tests__/stripe.test.ts @@ -129,6 +129,50 @@ describe("providers/stripe", () => { }); }); + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("archives Stripe products by marking them inactive", async () => { + const updateProduct = vi.fn().mockResolvedValue({ id: "prod_123" }); + const runtime = createStripeProvider( + { + products: { update: updateProduct }, + } as never, + { + secretKey: "sk_test_123", + webhookSecret: "whsec_123", + }, + ); + + await runtime.archiveProduct({ providerProductId: "prod_123" }); + + expect(updateProduct).toHaveBeenCalledWith("prod_123", { active: false }); + }); + + /** @see https://github.com/getpaykit/paykit/issues/123 */ + it("reactivates existing Stripe products during product sync", async () => { + const updateProduct = vi.fn().mockResolvedValue({ id: "prod_123" }); + const runtime = createStripeProvider( + { + products: { update: updateProduct }, + } as never, + { + secretKey: "sk_test_123", + webhookSecret: "whsec_123", + }, + ); + + const result = await runtime.syncProduct({ + existingProviderPriceId: "price_123", + existingProviderProductId: "prod_123", + id: "pro", + name: "Pro", + priceAmount: 2_000, + priceInterval: "month", + }); + + expect(updateProduct).toHaveBeenCalledWith("prod_123", { active: true, name: "Pro" }); + expect(result).toEqual({ providerPriceId: "price_123", providerProductId: "prod_123" }); + }); + /** @see https://github.com/getpaykit/paykit/issues/109 */ describe("managed payments", () => { function createCheckoutRuntime( diff --git a/packages/stripe/src/stripe-provider.ts b/packages/stripe/src/stripe-provider.ts index 8450ca42..8f477adf 100644 --- a/packages/stripe/src/stripe-provider.ts +++ b/packages/stripe/src/stripe-provider.ts @@ -838,6 +838,10 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): await client.paymentMethods.detach(data.providerMethodId); }, + async archiveProduct(data) { + await client.products.update(data.providerProductId, { active: false }); + }, + async syncProduct(data) { let providerProductId = data.existingProviderProductId; if (!providerProductId) { @@ -847,7 +851,7 @@ export function createStripeProvider(client: StripeSdk, options: StripeOptions): }); providerProductId = stripeProduct.id; } else { - await client.products.update(providerProductId, { name: data.name }); + await client.products.update(providerProductId, { active: true, name: data.name }); } if (data.existingProviderPriceId) { From 80e490c718333f5e66d86d6698ebdd4917210eb8 Mon Sep 17 00:00:00 2001 From: Blackmamoth Date: Tue, 14 Apr 2026 15:50:43 +0530 Subject: [PATCH 2/2] docs: document archived products on push --- landing/content/docs/concepts/cli.mdx | 2 ++ landing/content/docs/concepts/database.mdx | 1 + landing/content/docs/get-started/installation.mdx | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/landing/content/docs/concepts/cli.mdx b/landing/content/docs/concepts/cli.mdx index d239f1d5..2a97e477 100644 --- a/landing/content/docs/concepts/cli.mdx +++ b/landing/content/docs/concepts/cli.mdx @@ -32,6 +32,8 @@ Run it on initial setup and again whenever you change your plan configuration. When you change a plan and push, PayKit creates a new version of that plan. Old versions are never modified or deleted. This means running instances of your app keep working on their matching version while new code picks up the new one. +When you remove a plan from your config and push, PayKit archives the product locally instead of deleting it. Existing subscriptions and webhooks can still resolve the historical product. For Stripe, the provider product is marked inactive. + ### Production usage Run `push` before your app starts or builds, as part of your deploy pipeline. This way the updated product versions are ready in the database by the time your new code goes live. diff --git a/landing/content/docs/concepts/database.mdx b/landing/content/docs/concepts/database.mdx index fa4bc1b9..d9ff2a2b 100644 --- a/landing/content/docs/concepts/database.mdx +++ b/landing/content/docs/concepts/database.mdx @@ -60,3 +60,4 @@ PayKit keeps two sources of truth in sync: **From webhooks** (provider to database): Subscriptions, invoices, payment methods, and customer-provider ID mappings are synced automatically when provider events arrive. **From your config** (code to database): Plans, products, and features are synced from your `createPayKit` configuration when you run `paykitjs push`. Each time you change a plan and push, a new version is created. Previous versions are kept so running instances can continue serving existing subscriptions. +When you remove a plan from your config, `push` archives the product instead of deleting it. Archived products stay in the database for historical subscriptions and provider webhooks, and Stripe products are marked inactive. diff --git a/landing/content/docs/get-started/installation.mdx b/landing/content/docs/get-started/installation.mdx index 5ed6a5e5..39fc11d6 100644 --- a/landing/content/docs/get-started/installation.mdx +++ b/landing/content/docs/get-started/installation.mdx @@ -217,7 +217,7 @@ PayKit includes a CLI tool to keep your database in sync with your configuration This applies database migrations and syncs your plan definitions to provider's products. -
Run it once on setup, and every time after you change your products configuration. +
Run it once on setup, and every time after you change your products configuration, including removals.
For production deployments, see the [CLI reference](/docs/concepts/cli#production-usage).