Merge pull request #747 from useautumn/fix/invoice-cron-race
fix: invoice cron race
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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<CustomerProductsUpdatedPayload>({
|
||||
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<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({ customer, productId: pro.id });
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
balance: 200,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user