diff --git a/server/src/cron/invoiceCron/runInvoiceCron.ts b/server/src/cron/invoiceCron/runInvoiceCron.ts index 0b4f6946e..35df40c14 100644 --- a/server/src/cron/invoiceCron/runInvoiceCron.ts +++ b/server/src/cron/invoiceCron/runInvoiceCron.ts @@ -86,11 +86,7 @@ export const handleVoidInvoiceCron = async ({ } catch (error) { logger.error(`Error voiding invoice: ${error}`); } - } else if ( - invoice.status === "void" || - invoice.status === "paid" || - invoice.status === "uncollectible" - ) { + } else if (invoice.status === "void" || invoice.status === "uncollectible") { await MetadataService.delete({ db, id: metadata.id, diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 40ffc45c6..55db1f454 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -7,32 +7,26 @@ import chalk from "chalk"; /** * Test: Attach free default product, then attach pro with invoice mode */ -test.concurrent(`${chalk.yellowBright("invoice-mode: free default then pro with invoice checkout")}`, async () => { - const users = items.monthlyUsers({ includedUsage: 1 }); - const free = products.base({ - id: "free", - items: [items.monthlyMessages({ includedUsage: 100 }), users], - }); +test.concurrent(`${chalk.yellowBright("attach: pro plan with failed payment method")}`, async () => { + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const pro = products.pro({ id: "pro", - items: [items.monthlyMessages({ includedUsage: 100 }), users], - }); - const premium = products.premium({ - id: "premium", - items: [items.monthlyMessages({ includedUsage: 100 }), users], + items: [messagesItem], }); const { autumnV1 } = await initScenario({ - customerId: "test", + customerId: "test-failed-pm", setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [free, pro, premium] }), + s.customer({ paymentMethod: "fail" }), // Failed payment method + s.products({ list: [pro] }), ], actions: [], }); - await autumnV1.attach({ - customer_id: "test", + const result = await autumnV1.attach({ + customer_id: "test-failed-pm", product_id: pro.id, }); + + console.log("Attach response:", JSON.stringify(result, null, 2)); }); diff --git a/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts b/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts new file mode 100644 index 000000000..448aeaa5e --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/update-subscription-webhooks.test.ts @@ -0,0 +1,139 @@ +/** + * Integration tests for customer.products.updated webhook via UPDATE SUBSCRIPTION endpoint. + * Uses autumnV1.subscriptions.update() which calls handleUpdateSubscription. + * + * Verifies that webhooks are sent correctly for: + * - Update with price change: scenario "upgrade" (new customer product created) + * - Update with feature change only: scenario "upgrade" + * - Trial removal: scenario "upgrade" (trial ends, new billing cycle starts) + * + * Uses Svix Play (https://www.svix.com/play/) to receive and verify webhooks. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiProduct } from "@autumn/shared"; +import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { TestFeature } from "@tests/setup/v2Features.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + generatePlayToken, + getPlayWebhookUrl, + waitForWebhook, +} from "./utils/svixPlayClient.js"; +import { + createTestEndpoint, + deleteTestEndpoint, +} from "./utils/svixTestEndpoint.js"; + +type CustomerProductsUpdatedPayload = { + type: string; + data: { + scenario: string; + customer: ApiCustomerV3; + updated_product: ApiProduct; + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// SVIX PLAY SETUP (shared across all tests) +// ═══════════════════════════════════════════════════════════════════════════════ + +let playToken: string; +let endpointId: string; + +beforeAll(async () => { + // 1. Generate Svix Play token + playToken = await generatePlayToken(); + console.log(`Generated Svix Play token: ${playToken}`); + + // 2. Get org's Svix app ID + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (!svixAppId) { + throw new Error( + "Test org does not have svix_config.sandbox_app_id configured. " + + "Cannot run webhook integration tests without Svix app.", + ); + } + + // 3. Create Svix endpoint pointing to Svix Play + const playUrl = getPlayWebhookUrl(playToken); + console.log(`Creating Svix endpoint: ${playUrl}`); + endpointId = await createTestEndpoint({ appId: svixAppId, playUrl }); + console.log(`Created Svix endpoint: ${endpointId}`); +}); + +afterAll(async () => { + // Cleanup: delete Svix endpoint + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (svixAppId && endpointId) { + await deleteTestEndpoint({ appId: svixAppId, endpointId }); + console.log(`Deleted Svix endpoint: ${endpointId}`); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// UPDATE SUBSCRIPTION TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +test.concurrent(`${chalk.yellowBright("webhook update-sub: increase included usage - scenario: upgrade")}`, async () => { + const customerId = "webhook-update-usage"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const priceItem = items.monthlyPrice({ price: 20 }); + const pro = products.base({ + id: "pro", + items: [messagesItem, priceItem], + }); + + // Setup: customer with Pro ($20/month, 100 messages) attached + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success", skipWebhooks: true }), + s.products({ list: [pro] }), + ], + actions: [s.attach({ productId: pro.id })], + }); + + // Action: update subscription to increase included usage from 100 to 200 (same price) + const newMessagesItem = items.monthlyMessages({ includedUsage: 200 }); + + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + items: [newMessagesItem, priceItem], + }); + + // Assert: webhook received with scenario "upgrade" + const result = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "upgrade" && + payload.data?.updated_product?.id === pro.id, + timeoutMs: 15000, + }); + + expect(result).not.toBeNull(); + expect(result?.payload.type).toBe("customer.products.updated"); + + const { data } = result!.payload; + expect(data.scenario).toBe("upgrade"); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer.id).toBe(customerId); + + // Verify customer state: Pro is active with new features + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 200, + }); +});