merged from main
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type AppEnv,
|
||||
AppEnv,
|
||||
type FullProduct,
|
||||
formatAmount,
|
||||
getProductItemDisplay,
|
||||
@@ -148,11 +148,17 @@ const listVercelPlansForOrg = async ({
|
||||
metadata?: Record<string, any>;
|
||||
canCancel?: boolean;
|
||||
}) => {
|
||||
const allowedIds =
|
||||
(env === AppEnv.Live
|
||||
? org.processor_configs?.vercel?.allowed_product_ids_live
|
||||
: org.processor_configs?.vercel?.allowed_product_ids_sandbox) ?? [];
|
||||
|
||||
const products = await ProductService.listFull({
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
archived: false,
|
||||
inIds: allowedIds.length > 0 ? allowedIds : undefined,
|
||||
});
|
||||
|
||||
sortProductsByPrice({ products });
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { cusProductToProduct } from "@autumn/shared";
|
||||
import {
|
||||
cusProductToProduct,
|
||||
mapToProductV2,
|
||||
type FullCusProduct,
|
||||
productV2ToBasePrice,
|
||||
} from "@autumn/shared";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { VercelBillingPlan } from "../../misc/vercelTypes.js";
|
||||
@@ -26,15 +31,29 @@ export const handleGetInstallation = createRoute({
|
||||
);
|
||||
}
|
||||
|
||||
const getPlanAmount = (cusProduct: FullCusProduct) => {
|
||||
const product = cusProductToProduct({ cusProduct });
|
||||
const productV2 = mapToProductV2({ product });
|
||||
const basePrice = productV2ToBasePrice({ product: productV2 });
|
||||
return basePrice?.price ?? 0;
|
||||
};
|
||||
|
||||
const nonAddonProducts = (customer.customer_products || []).filter(
|
||||
(customerProduct) => !customerProduct.product.is_add_on,
|
||||
);
|
||||
|
||||
const customerProduct =
|
||||
nonAddonProducts.sort((a, b) => getPlanAmount(b) - getPlanAmount(a))[0] ??
|
||||
customer.customer_products?.[0];
|
||||
|
||||
return c.json(
|
||||
{
|
||||
notification: null,
|
||||
billingPlan:
|
||||
// edge case: [0] = add-on [1] = main
|
||||
customer.customer_products?.[0] !== undefined
|
||||
customerProduct !== undefined
|
||||
? (productToBillingPlan({
|
||||
product: cusProductToProduct({
|
||||
cusProduct: customer.customer_products?.[0],
|
||||
cusProduct: customerProduct,
|
||||
}),
|
||||
orgCurrency: org?.default_currency ?? "usd",
|
||||
}) satisfies VercelBillingPlan)
|
||||
|
||||
@@ -25,12 +25,12 @@ export const setupDefaultProductContext = async ({
|
||||
if (nullish(params.cancel_action)) return undefined;
|
||||
|
||||
// Add-ons don't trigger default products
|
||||
const { valid: isMainCustomerScopedAndPaid } = cp(customerProduct)
|
||||
const { valid: isMainCustomerScopedAndRecurring } = cp(customerProduct)
|
||||
.main()
|
||||
.paidRecurring()
|
||||
.recurring()
|
||||
.customerScoped();
|
||||
|
||||
if (!isMainCustomerScopedAndPaid) return undefined;
|
||||
if (!isMainCustomerScopedAndRecurring) return undefined;
|
||||
|
||||
const defaultProduct = await getFreeDefaultProductByGroup({
|
||||
ctx,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { createStripeCli } from "@server/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type Stripe from "stripe";
|
||||
import { logSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction";
|
||||
import type {
|
||||
BillingContext,
|
||||
StripeSubscriptionScheduleAction,
|
||||
} from "@autumn/shared";
|
||||
import { createStripeCli } from "@server/external/connect/createStripeCli";
|
||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||
import type Stripe from "stripe";
|
||||
import { logSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction";
|
||||
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
|
||||
|
||||
/**
|
||||
* Maps update phase format to create phase format (strips start_date).
|
||||
* Preserves discounts so they carry forward to the new schedule phases.
|
||||
*/
|
||||
const toCreatePhase = (
|
||||
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
@@ -19,6 +20,9 @@ const toCreatePhase = (
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
end_date: typeof phase.end_date === "number" ? phase.end_date : undefined,
|
||||
discounts: phase.discounts as
|
||||
| Stripe.SubscriptionScheduleCreateParams.Phase.Discount[]
|
||||
| undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import type { BillingContext, StripeDiscountWithCoupon } from "@autumn/shared";
|
||||
import {
|
||||
type FullCusProduct,
|
||||
msToSeconds,
|
||||
@@ -74,6 +74,23 @@ const customerProductsToPhaseItems = ({
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts billing context discounts to the format expected by Stripe schedule phases.
|
||||
* Uses the existing discount ID so Stripe reuses the same discount object,
|
||||
* preserving the original start/end timestamps and remaining duration for repeating coupons.
|
||||
*/
|
||||
const stripeDiscountsToPhaseDiscounts = ({
|
||||
stripeDiscounts,
|
||||
}: {
|
||||
stripeDiscounts?: StripeDiscountWithCoupon[];
|
||||
}): Stripe.SubscriptionScheduleUpdateParams.Phase.Discount[] | undefined => {
|
||||
if (!stripeDiscounts || stripeDiscounts.length === 0) return undefined;
|
||||
|
||||
return stripeDiscounts.map((discount) => ({
|
||||
discount: discount.source.coupon.id,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds Stripe subscription schedule phases.
|
||||
*
|
||||
@@ -174,7 +191,9 @@ export const buildStripePhasesUpdate = ({
|
||||
start_date: msToSeconds(startMs),
|
||||
end_date: endMs ? msToSeconds(endMs) : undefined,
|
||||
trial_end: computePhaseTrialEndsAt(),
|
||||
discounts,
|
||||
discounts: stripeDiscountsToPhaseDiscounts({
|
||||
stripeDiscounts: billingContext.stripeDiscounts,
|
||||
}),
|
||||
};
|
||||
|
||||
// Log phase details
|
||||
|
||||
@@ -36,6 +36,8 @@ export const getVercelConfigDisplay = ({
|
||||
webhook_url: undefined,
|
||||
custom_payment_method: undefined,
|
||||
marketplace_mode: undefined,
|
||||
allowed_product_ids_live: undefined,
|
||||
allowed_product_ids_sandbox: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,15 +66,31 @@ export const getVercelConfigDisplay = ({
|
||||
webhook_url: mask(webhookUrl, 8, 6),
|
||||
custom_payment_method: mask(customPaymentMethod, 5, 3),
|
||||
marketplace_mode: vercelConfig.marketplace_mode,
|
||||
allowed_product_ids_live: vercelConfig.allowed_product_ids_live,
|
||||
allowed_product_ids_sandbox: vercelConfig.allowed_product_ids_sandbox,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleUpsertVercelConfig = createRoute({
|
||||
body: UpsertVercelProcessorConfigSchema,
|
||||
handler: async (c) => {
|
||||
const { db, org } = c.get("ctx");
|
||||
const { db, org, env } = c.get("ctx");
|
||||
|
||||
const body = c.req.valid("json");
|
||||
const normalizeAllowedProductIds = (ids: string[] | undefined) => {
|
||||
return Array.from(
|
||||
new Set(ids?.map((id) => id.trim()).filter((id) => !!id)),
|
||||
);
|
||||
};
|
||||
|
||||
const liveAllowedProductIds =
|
||||
body.allowed_product_ids_live !== undefined
|
||||
? normalizeAllowedProductIds(body.allowed_product_ids_live)
|
||||
: undefined;
|
||||
const sandboxAllowedProductIds =
|
||||
body.allowed_product_ids_sandbox !== undefined
|
||||
? normalizeAllowedProductIds(body.allowed_product_ids_sandbox)
|
||||
: undefined;
|
||||
|
||||
// Merge with existing processor_configs to avoid unsetting fields
|
||||
const existingVercelConfig =
|
||||
@@ -87,6 +105,43 @@ export const handleUpsertVercelConfig = createRoute({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const envSpecificClientConfig: Partial<VercelProcessorConfig> = {};
|
||||
|
||||
if (env === AppEnv.Live) {
|
||||
if (body.client_integration_id) {
|
||||
envSpecificClientConfig.client_integration_id =
|
||||
body.client_integration_id;
|
||||
}
|
||||
if (body.client_secret) {
|
||||
envSpecificClientConfig.client_secret = body.client_secret;
|
||||
}
|
||||
if (body.webhook_url) {
|
||||
envSpecificClientConfig.webhook_url = body.webhook_url;
|
||||
}
|
||||
} else {
|
||||
if (body.client_integration_id || body.sandbox_client_id) {
|
||||
envSpecificClientConfig.sandbox_client_id =
|
||||
body.client_integration_id || body.sandbox_client_id;
|
||||
}
|
||||
if (body.client_secret || body.sandbox_client_secret) {
|
||||
envSpecificClientConfig.sandbox_client_secret =
|
||||
body.client_secret || body.sandbox_client_secret;
|
||||
}
|
||||
if (body.webhook_url || body.sandbox_webhook_url) {
|
||||
envSpecificClientConfig.sandbox_webhook_url =
|
||||
body.webhook_url || body.sandbox_webhook_url;
|
||||
}
|
||||
}
|
||||
|
||||
const allowedProductIdsUpdates: Partial<VercelProcessorConfig> = {};
|
||||
if (env === AppEnv.Live && liveAllowedProductIds !== undefined) {
|
||||
allowedProductIdsUpdates.allowed_product_ids_live = liveAllowedProductIds;
|
||||
}
|
||||
if (env === AppEnv.Sandbox && sandboxAllowedProductIds !== undefined) {
|
||||
allowedProductIdsUpdates.allowed_product_ids_sandbox =
|
||||
sandboxAllowedProductIds;
|
||||
}
|
||||
|
||||
await OrgService.update({
|
||||
db,
|
||||
orgId: org.id,
|
||||
@@ -95,24 +150,8 @@ export const handleUpsertVercelConfig = createRoute({
|
||||
...org.processor_configs,
|
||||
vercel: {
|
||||
...existingVercelConfig,
|
||||
// Live fields
|
||||
...(body.client_integration_id
|
||||
? { client_integration_id: body.client_integration_id }
|
||||
: {}),
|
||||
...(body.client_secret
|
||||
? { client_secret: body.client_secret }
|
||||
: {}),
|
||||
...(body.webhook_url ? { webhook_url: body.webhook_url } : {}),
|
||||
// Sandbox fields
|
||||
...(body.sandbox_client_id
|
||||
? { sandbox_client_id: body.sandbox_client_id }
|
||||
: {}),
|
||||
...(body.sandbox_client_secret
|
||||
? { sandbox_client_secret: body.sandbox_client_secret }
|
||||
: {}),
|
||||
...(body.sandbox_webhook_url
|
||||
? { sandbox_webhook_url: body.sandbox_webhook_url }
|
||||
: {}),
|
||||
...allowedProductIdsUpdates,
|
||||
...envSpecificClientConfig,
|
||||
// Custom payment method (for both envs)
|
||||
...(customPaymentMethod
|
||||
? { custom_payment_method: customPaymentMethod }
|
||||
|
||||
@@ -245,6 +245,9 @@ export const createOrgResponse = ({
|
||||
webhook_url: vercelConnection.webhook_url,
|
||||
custom_payment_method: vercelConnection.custom_payment_method,
|
||||
marketplace_mode: vercelConnection.marketplace_mode,
|
||||
allowed_product_ids_live: vercelConnection.allowed_product_ids_live,
|
||||
allowed_product_ids_sandbox:
|
||||
vercelConnection.allowed_product_ids_sandbox,
|
||||
},
|
||||
revenuecat: {
|
||||
connected: revenueCatConnection.connected,
|
||||
|
||||
@@ -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,794 @@
|
||||
/**
|
||||
* Scheduled Switch Discount Tests (Attach V2)
|
||||
*
|
||||
* Tests that discounts (coupons) applied to a Stripe subscription are preserved
|
||||
* when a plan is downgraded via scheduled switch.
|
||||
*
|
||||
* The bug: When creating subscription schedule phases in buildStripePhasesUpdate,
|
||||
* the `discounts` parameter is NOT set on the phases. This means when the subscription
|
||||
* transitions to the next phase at billing cycle end, discounts are lost.
|
||||
*
|
||||
* Key behaviors tested:
|
||||
* - Percent-off discount persists after scheduling a downgrade
|
||||
* - Amount-off discount persists after scheduling a downgrade
|
||||
* - Discount persists after advancing cycle (phase transition)
|
||||
* - Discount survives when replacing a scheduled downgrade with another
|
||||
* - Multiple discounts survive scheduling
|
||||
* - Discount survives upgrade that cancels a scheduled downgrade
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import {
|
||||
applySubscriptionDiscount,
|
||||
createAmountCoupon,
|
||||
createPercentCoupon,
|
||||
getStripeSubscription,
|
||||
} from "@tests/integration/billing/utils/discounts/discountTestUtils";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import {
|
||||
expectCustomerProducts,
|
||||
expectProductCanceling,
|
||||
expectProductScheduled,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
/**
|
||||
* Helper to extract coupon ID from a Stripe discount object.
|
||||
* Handles both string and expanded object forms.
|
||||
*/
|
||||
const extractCouponId = (discount: unknown): string | null => {
|
||||
if (typeof discount === "string") return discount;
|
||||
if (
|
||||
discount &&
|
||||
typeof discount === "object" &&
|
||||
"source" in discount &&
|
||||
discount.source &&
|
||||
typeof discount.source === "object" &&
|
||||
"coupon" in discount.source &&
|
||||
discount.source.coupon &&
|
||||
typeof discount.source.coupon === "object" &&
|
||||
"id" in discount.source.coupon
|
||||
) {
|
||||
return discount.source.coupon.id as string;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Premium to Pro with 20% discount - verify discount on sub after schedule
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with 20% off coupon on subscription
|
||||
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
|
||||
*
|
||||
* Expected Result:
|
||||
* - Discount still present on Stripe subscription after scheduling the downgrade
|
||||
* - Premium is canceling, pro is scheduled
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 1: 20% discount preserved after scheduling downgrade")}`, async () => {
|
||||
const customerId = "sched-switch-discount-20pct";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Apply 20% discount to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Verify discount is applied before downgrade
|
||||
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify product states
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Verify discount is still on the subscription after scheduling the downgrade
|
||||
const { subscription: subAfter } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
|
||||
// KEY ASSERTION: discount should still be present
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Premium to Pro with $10 off discount - verify discount after schedule
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with $10 off coupon on subscription
|
||||
* - Downgrade to pro ($20/mo) - scheduled for end of cycle
|
||||
*
|
||||
* Expected Result:
|
||||
* - Amount-off discount still present on subscription after scheduling
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 2: $10 off discount preserved after scheduling downgrade")}`, async () => {
|
||||
const customerId = "sched-switch-discount-10off";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Apply $10 off discount to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createAmountCoupon({
|
||||
stripeCli,
|
||||
amountOffCents: 1000,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify product states
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Verify discount is still on the subscription
|
||||
const { subscription: subAfter } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Premium to Pro with discount - advance cycle, verify discount survives
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with 20% off coupon
|
||||
* - Downgrade to pro ($20/mo) - scheduled
|
||||
* - Advance test clock to next billing cycle
|
||||
*
|
||||
* Expected Result:
|
||||
* - After cycle: pro is active, premium removed
|
||||
* - Discount should STILL be on the subscription after the phase transition
|
||||
*
|
||||
* THIS TEST EXPOSES THE BUG: subscription schedule phases don't carry discounts,
|
||||
* so when the subscription transitions to the pro phase, the discount is lost.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 3: 20% discount preserved after cycle advance")}`, async () => {
|
||||
const customerId = "sched-switch-discount-cycle";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Apply 20% discount to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Verify discount applied before downgrade
|
||||
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify discount still present after scheduling
|
||||
const { subscription: subMid } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subMidExpanded = await stripeCli.subscriptions.retrieve(subMid.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
expect(subMidExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Advance to next billing cycle (discount is still on the subscription)
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
currentEpochMs: advancedTo,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
const customerAfterCycle =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Verify pro is active, premium removed
|
||||
await expectCustomerProducts({
|
||||
customer: customerAfterCycle,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
|
||||
// Verify features updated to pro tier
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfterCycle,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// KEY BUG CHECK: verify discount survives the phase transition
|
||||
const { subscription: subAfterCycle } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
|
||||
subAfterCycle.id,
|
||||
{ expand: ["discounts.source.coupon"] },
|
||||
);
|
||||
|
||||
// The discount should still be present after the scheduled switch completed
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Premium to Pro (scheduled) to Free (replace) - discount preserved
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with 20% off coupon
|
||||
* - Downgrade to pro ($20/mo) - scheduled
|
||||
* - Replace scheduled with free - re-schedules
|
||||
*
|
||||
* Expected Result:
|
||||
* - Discount still on subscription after replacing the scheduled downgrade
|
||||
*
|
||||
* The schedule is released and recreated during replacement. This test verifies
|
||||
* that the discount survives the release + recreate cycle.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 4: discount preserved when replacing scheduled downgrade")}`, async () => {
|
||||
const customerId = "sched-switch-discount-replace";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [free, pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Apply 20% discount to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify mid-state
|
||||
const customerMid = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer: customerMid,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer: customerMid,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Replace scheduled pro with free
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify product states after replacement
|
||||
const customerAfterReplace =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer: customerAfterReplace,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer: customerAfterReplace,
|
||||
productId: free.id,
|
||||
});
|
||||
|
||||
// Verify discount is still on the subscription after schedule replacement
|
||||
const { subscription: subAfterReplace } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
|
||||
subAfterReplace.id,
|
||||
{ expand: ["discounts.source.coupon"] },
|
||||
);
|
||||
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: Multiple discounts preserved after scheduled downgrade
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with two discounts (20% off + $5 off)
|
||||
* - Downgrade to pro ($20/mo) - scheduled
|
||||
*
|
||||
* Expected Result:
|
||||
* - Both discounts still present on subscription after scheduling
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 5: multiple discounts preserved after scheduling")}`, async () => {
|
||||
const customerId = "sched-switch-discount-multi";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Apply two discounts to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const percentCoupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
const amountCoupon = await createAmountCoupon({
|
||||
stripeCli,
|
||||
amountOffCents: 500,
|
||||
});
|
||||
|
||||
// Apply both discounts
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [percentCoupon.id, amountCoupon.id],
|
||||
});
|
||||
|
||||
// Verify both discounts applied
|
||||
const subWithDiscounts = await stripeCli.subscriptions.retrieve(
|
||||
subBefore.id,
|
||||
{ expand: ["discounts.source.coupon"] },
|
||||
);
|
||||
expect(subWithDiscounts.discounts?.length).toBe(2);
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify product states
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Verify BOTH discounts are still on the subscription
|
||||
const { subscription: subAfter } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
|
||||
expect(subAfterExpanded.discounts?.length).toBe(2);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 6: Upgrade from scheduled downgrade - discount preserved
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with 20% off coupon
|
||||
* - Downgrade to pro ($20/mo) - scheduled
|
||||
* - Upgrade to ultra ($200/mo) - immediate, cancels schedule
|
||||
*
|
||||
* Expected Result:
|
||||
* - Discount should still be on the subscription after upgrade cancels the schedule
|
||||
* - Ultra is active, premium and pro are removed
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 6: discount preserved after upgrade cancels scheduled downgrade")}`, async () => {
|
||||
const customerId = "sched-switch-discount-upgrade";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const ultraMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 5000,
|
||||
});
|
||||
const ultra = products.ultra({
|
||||
id: "ultra",
|
||||
items: [ultraMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, ultra] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: premium.id }),
|
||||
s.billing.attach({ productId: pro.id }),
|
||||
],
|
||||
});
|
||||
|
||||
// Apply 20% discount to the subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Verify scheduled state
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductCanceling({
|
||||
customer: customerBefore,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductScheduled({
|
||||
customer: customerBefore,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Upgrade to ultra (should cancel scheduled downgrade)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: ultra.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Verify ultra is active, premium and pro removed
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [ultra.id],
|
||||
notPresent: [premium.id, pro.id],
|
||||
});
|
||||
|
||||
// Verify discount is still on the subscription after upgrade
|
||||
const { subscription: subAfter } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(subAfter.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
expect(extractCouponId(subAfterExpanded.discounts?.[0])).toBe(coupon.id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 7: Repeating coupon duration preserved across phase transition
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has premium ($50/mo) with 3-month repeating 20% off coupon
|
||||
* - Downgrade to pro ($20/mo) - scheduled
|
||||
* - Advance test clock past one billing cycle
|
||||
*
|
||||
* Expected Result:
|
||||
* - After cycle: pro is active with discount still present
|
||||
* - The discount's `end` timestamp should be the SAME as the original
|
||||
* (not reset to phase2_start + 3 months)
|
||||
* - This means if 1 month was used on premium, only 2 months remain on pro
|
||||
*
|
||||
* This test catches the bug where using `coupon: couponId` on phases
|
||||
* creates a fresh discount with a reset duration, instead of using
|
||||
* `discount: discountId` to preserve the original duration.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("scheduled-switch-discounts 7: repeating coupon duration preserved across phase transition")}`, async () => {
|
||||
const customerId = "sched-switch-discount-duration";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1, ctx, testClockId, advancedTo } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: premium.id })],
|
||||
});
|
||||
|
||||
// Create a 3-month repeating coupon and apply to subscription
|
||||
const { stripeCli, subscription: subBefore } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
|
||||
const coupon = await createPercentCoupon({
|
||||
stripeCli,
|
||||
percentOff: 20,
|
||||
duration: "repeating",
|
||||
durationInMonths: 3,
|
||||
});
|
||||
|
||||
await applySubscriptionDiscount({
|
||||
stripeCli,
|
||||
subscriptionId: subBefore.id,
|
||||
couponIds: [coupon.id],
|
||||
});
|
||||
|
||||
// Record the original discount's end timestamp
|
||||
const subWithDiscount = await stripeCli.subscriptions.retrieve(subBefore.id, {
|
||||
expand: ["discounts.source.coupon"],
|
||||
});
|
||||
expect(subWithDiscount.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const originalDiscount = subWithDiscount.discounts?.[0];
|
||||
expect(originalDiscount).toBeDefined();
|
||||
const originalDiscountEnd =
|
||||
typeof originalDiscount !== "string" ? originalDiscount?.end : null;
|
||||
expect(originalDiscountEnd).not.toBeNull();
|
||||
|
||||
// Schedule downgrade to pro
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
redirect_mode: "if_required",
|
||||
});
|
||||
|
||||
// Advance to next billing cycle
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
currentEpochMs: advancedTo,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
// Verify pro is now active
|
||||
const customerAfterCycle =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerProducts({
|
||||
customer: customerAfterCycle,
|
||||
active: [pro.id],
|
||||
notPresent: [premium.id],
|
||||
});
|
||||
|
||||
// KEY ASSERTION: discount still present AND end timestamp is preserved
|
||||
const { subscription: subAfterCycle } = await getStripeSubscription({
|
||||
customerId,
|
||||
});
|
||||
const subAfterExpanded = await stripeCli.subscriptions.retrieve(
|
||||
subAfterCycle.id,
|
||||
{ expand: ["discounts.source.coupon"] },
|
||||
);
|
||||
|
||||
expect(subAfterExpanded.discounts?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const discountAfterCycle = subAfterExpanded.discounts?.[0];
|
||||
const discountEndAfterCycle =
|
||||
typeof discountAfterCycle !== "string" ? discountAfterCycle?.end : null;
|
||||
|
||||
// The discount end should be the SAME as the original - not reset
|
||||
// If it was reset, it would be ~phase2_start + 3 months (much later)
|
||||
expect(discountEndAfterCycle).toBe(originalDiscountEnd);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,8 @@ export const VercelProcessorConfigSchema = z.object({
|
||||
sandbox_client_secret: z.string().optional(),
|
||||
sandbox_webhook_url: z.string().optional(),
|
||||
webhook_url: z.string(),
|
||||
allowed_product_ids_live: z.array(z.string().min(1)).optional(),
|
||||
allowed_product_ids_sandbox: z.array(z.string().min(1)).optional(),
|
||||
custom_payment_method: z
|
||||
.object({
|
||||
live: z.string().optional(),
|
||||
@@ -78,6 +80,8 @@ export const UpsertVercelProcessorConfigSchema = z.object({
|
||||
sandbox_client_id: z.string().min(8).optional(),
|
||||
sandbox_client_secret: z.string().min(8).optional(),
|
||||
sandbox_webhook_url: z.string().min(14).optional(),
|
||||
allowed_product_ids_live: z.array(z.string().min(1)).optional(),
|
||||
allowed_product_ids_sandbox: z.array(z.string().min(1)).optional(),
|
||||
custom_payment_method: z
|
||||
.object({
|
||||
live: z.string().min(8).optional(),
|
||||
|
||||
@@ -35,6 +35,8 @@ export const FrontendOrgSchema = z.object({
|
||||
webhook_url: z.string().optional(),
|
||||
custom_payment_method: z.string().optional(),
|
||||
marketplace_mode: z.enum(VercelMarketplaceMode).optional(),
|
||||
allowed_product_ids_live: z.array(z.string()).optional(),
|
||||
allowed_product_ids_sandbox: z.array(z.string()).optional(),
|
||||
}),
|
||||
revenuecat: z.object({
|
||||
connected: z.boolean(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
VercelMarketplaceMode,
|
||||
} from "@autumn/shared";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AppPortal } from "svix-react";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
} from "@/components/v2/cards/Card";
|
||||
import { FormLabel } from "@/components/v2/form/FormLabel";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
import { TagSelect } from "@/components/v2/selects/TagSelect";
|
||||
import { useTheme } from "@/contexts/ThemeProvider";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
import { useVercelQuery } from "@/hooks/queries/useVercelQuery";
|
||||
import { OrgService } from "@/services/OrgService";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
@@ -33,6 +35,16 @@ import { useEnv } from "@/utils/envUtils";
|
||||
import { getBackendErr } from "@/utils/genUtils";
|
||||
import LoadingScreen from "@/views/general/LoadingScreen";
|
||||
|
||||
type VercelConfigState = {
|
||||
client_integration_id: string;
|
||||
client_secret: string;
|
||||
webhook_url: string;
|
||||
custom_payment_method: string;
|
||||
marketplace_mode: VercelMarketplaceMode;
|
||||
allowed_product_ids_live: string[];
|
||||
allowed_product_ids_sandbox: string[];
|
||||
};
|
||||
|
||||
export const ConfigureVercel = () => {
|
||||
const { org, isLoading, mutate } = useOrg();
|
||||
const {
|
||||
@@ -40,33 +52,71 @@ export const ConfigureVercel = () => {
|
||||
isLoading: isVercelLoading,
|
||||
error: vercelError,
|
||||
} = useVercelQuery();
|
||||
const { products } = useProductsQuery();
|
||||
const env = useEnv();
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const [vercelConfig, setVercelConfig] = useState({
|
||||
const [vercelConfig, setVercelConfig] = useState<VercelConfigState>({
|
||||
client_integration_id: "",
|
||||
client_secret: "",
|
||||
webhook_url: "",
|
||||
custom_payment_method: "",
|
||||
marketplace_mode: "" as VercelMarketplaceMode,
|
||||
allowed_product_ids_live: [],
|
||||
allowed_product_ids_sandbox: [],
|
||||
});
|
||||
|
||||
const activeAllowedProductIds =
|
||||
env === "live"
|
||||
? vercelConfig.allowed_product_ids_live
|
||||
: vercelConfig.allowed_product_ids_sandbox;
|
||||
|
||||
const setActiveAllowedProductIds = (values: string[]) => {
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
...(env === "live"
|
||||
? { allowed_product_ids_live: values }
|
||||
: { allowed_product_ids_sandbox: values }),
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const vercelOrgConfig = org?.processor_configs?.vercel;
|
||||
if (!vercelOrgConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
allowed_product_ids_live:
|
||||
vercelOrgConfig.allowed_product_ids_live ??
|
||||
[],
|
||||
allowed_product_ids_sandbox:
|
||||
vercelOrgConfig.allowed_product_ids_sandbox ?? [],
|
||||
}));
|
||||
}, [org]);
|
||||
|
||||
const productOptions = products
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((product) => ({
|
||||
label: `${product.name} (${product.id})`,
|
||||
value: product.id,
|
||||
}));
|
||||
|
||||
const currentClientIntegrationId = org?.processor_configs?.vercel?.client_integration_id
|
||||
const currentClientSecret = org?.processor_configs?.vercel?.client_secret
|
||||
const currentWebhookUrl = org?.processor_configs?.vercel?.webhook_url
|
||||
const currentCustomPaymentMethod = org?.processor_configs?.vercel?.custom_payment_method
|
||||
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const handleSaveVercelConfig = async (
|
||||
axiosInstance: AxiosInstance,
|
||||
vercelConfig: {
|
||||
client_integration_id?: string;
|
||||
client_secret?: string;
|
||||
webhook_url?: string;
|
||||
custom_payment_method?: string;
|
||||
marketplace_mode?: VercelMarketplaceMode;
|
||||
},
|
||||
vercelConfig: VercelConfigState,
|
||||
) => {
|
||||
try {
|
||||
// Map generic field names to env-specific field names
|
||||
const filteredConfig: UpsertVercelProcessorConfig = {};
|
||||
|
||||
// Map to correct field names based on current env
|
||||
if (vercelConfig.client_integration_id?.trim()) {
|
||||
if (env === "live") {
|
||||
filteredConfig.client_integration_id =
|
||||
@@ -90,7 +140,8 @@ export const ConfigureVercel = () => {
|
||||
if (env === "live") {
|
||||
filteredConfig.webhook_url = vercelConfig.webhook_url.trim();
|
||||
} else {
|
||||
filteredConfig.sandbox_webhook_url = vercelConfig.webhook_url.trim();
|
||||
filteredConfig.sandbox_webhook_url =
|
||||
vercelConfig.webhook_url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +155,14 @@ export const ConfigureVercel = () => {
|
||||
filteredConfig.marketplace_mode = vercelConfig.marketplace_mode;
|
||||
}
|
||||
|
||||
if (env === "live") {
|
||||
filteredConfig.allowed_product_ids_live =
|
||||
vercelConfig.allowed_product_ids_live;
|
||||
} else {
|
||||
filteredConfig.allowed_product_ids_sandbox =
|
||||
vercelConfig.allowed_product_ids_sandbox;
|
||||
}
|
||||
|
||||
const res = await OrgService.upsertVercelConfig(
|
||||
axiosInstance,
|
||||
filteredConfig,
|
||||
@@ -111,15 +170,15 @@ export const ConfigureVercel = () => {
|
||||
if (res.status === 200) {
|
||||
toast.success("Vercel config updated successfully");
|
||||
await mutate();
|
||||
// Clear the form after successful update
|
||||
setVercelConfig({
|
||||
// Clear credential fields, keep product filters
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
client_integration_id: "",
|
||||
client_secret: "",
|
||||
webhook_url: "",
|
||||
custom_payment_method: "",
|
||||
marketplace_mode:
|
||||
filteredConfig.marketplace_mode as VercelMarketplaceMode,
|
||||
});
|
||||
marketplace_mode: filteredConfig.marketplace_mode as VercelMarketplaceMode,
|
||||
}));
|
||||
} else {
|
||||
toast.error("Failed to update Vercel config");
|
||||
}
|
||||
@@ -158,13 +217,13 @@ export const ConfigureVercel = () => {
|
||||
<Input
|
||||
value={vercelConfig.client_integration_id || ""}
|
||||
onChange={(e) =>
|
||||
setVercelConfig({
|
||||
...vercelConfig,
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
client_integration_id: e.target.value,
|
||||
})
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
org?.processor_configs?.vercel?.client_integration_id ||
|
||||
currentClientIntegrationId ||
|
||||
"eg. oac_2ttbjWcOQ0pyH1v9wYkROKB3"
|
||||
}
|
||||
/>
|
||||
@@ -176,17 +235,34 @@ export const ConfigureVercel = () => {
|
||||
<Input
|
||||
value={vercelConfig.client_secret || ""}
|
||||
onChange={(e) =>
|
||||
setVercelConfig({
|
||||
...vercelConfig,
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
client_secret: e.target.value,
|
||||
})
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
org?.processor_configs?.vercel?.client_secret ||
|
||||
currentClientSecret ||
|
||||
"eg. VAxvZFz8ST4d5b9pa2EuXkWG"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel className="mb-1">
|
||||
<span className="text-t2">Webhook URL</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
value={vercelConfig.webhook_url || ""}
|
||||
onChange={(e) =>
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
webhook_url: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
currentWebhookUrl || "eg. https://example.com/api/webhooks"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel className="mb-1">
|
||||
<span className="text-t2">
|
||||
@@ -208,17 +284,77 @@ export const ConfigureVercel = () => {
|
||||
<Input
|
||||
value={vercelConfig.custom_payment_method || ""}
|
||||
onChange={(e) =>
|
||||
setVercelConfig({
|
||||
...vercelConfig,
|
||||
setVercelConfig((prev) => ({
|
||||
...prev,
|
||||
custom_payment_method: e.target.value,
|
||||
})
|
||||
}))
|
||||
}
|
||||
placeholder={
|
||||
org?.processor_configs?.vercel?.custom_payment_method ||
|
||||
currentCustomPaymentMethod ||
|
||||
"eg. cpmt_Yij7OBT6Fxu0UOa12XguA0vGB"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FormLabel className="mb-1">
|
||||
<span className="text-t2">Allowed Product IDs (Optional)</span>
|
||||
</FormLabel>
|
||||
<p className="text-t3 text-sm mb-2">
|
||||
Optional: choose product IDs to show in Vercel plans.
|
||||
</p>
|
||||
<TagSelect
|
||||
value={activeAllowedProductIds}
|
||||
onChange={setActiveAllowedProductIds}
|
||||
options={productOptions}
|
||||
placeholder="Select product IDs to include"
|
||||
showAllProducts={activeAllowedProductIds.length === 0}
|
||||
renderContent={(setOpen) => (
|
||||
<>
|
||||
{productOptions.length === 0 ? (
|
||||
<div className="p-2 text-t6">No products found</div>
|
||||
) : (
|
||||
productOptions.map((option) => {
|
||||
const isSelected =
|
||||
activeAllowedProductIds.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
onClick={() => {
|
||||
const nextValue = isSelected
|
||||
? activeAllowedProductIds.filter(
|
||||
(id) => id !== option.value,
|
||||
)
|
||||
: [...activeAllowedProductIds, option.value];
|
||||
setActiveAllowedProductIds(nextValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const nextValue = isSelected
|
||||
? activeAllowedProductIds.filter(
|
||||
(id) => id !== option.value,
|
||||
)
|
||||
: [...activeAllowedProductIds, option.value];
|
||||
setActiveAllowedProductIds(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="truncate">{option.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
|
||||
Reference in New Issue
Block a user