Merge branch 'main' into dev
This commit is contained in:
@@ -9,6 +9,7 @@ import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
|
||||
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
import { workflows } from "@/queue/workflows";
|
||||
import { addToExtraLogs } from "@/utils/logging/addToExtraLogs";
|
||||
@@ -70,6 +71,13 @@ export const handleCheckoutSessionMetadataV2 = async ({
|
||||
stripeInvoice: checkoutContext.stripeInvoice,
|
||||
});
|
||||
|
||||
// Queue customer.products.updated webhook (mirrors executeBillingPlan)
|
||||
await billingPlanToSendProductsUpdated({
|
||||
ctx,
|
||||
autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
|
||||
billingContext: updatedDeferredData.billingContext,
|
||||
});
|
||||
|
||||
// Delete metadata after successful execution
|
||||
await MetadataService.delete({ db: ctx.db, id: metadata.id });
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
customerProducts,
|
||||
customers,
|
||||
type FullCustomer,
|
||||
products,
|
||||
} from "@autumn/shared";
|
||||
import { and, eq, isNotNull, or } from "drizzle-orm";
|
||||
import type { RepoContext } from "@/db/repoContext.js";
|
||||
|
||||
/** Fetch all customer_products rows with a free trial for a given customer (or matching fingerprint). */
|
||||
export const fetchCustomerProductFreeTrials = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
}: {
|
||||
ctx: RepoContext;
|
||||
fullCus: FullCustomer;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
plan_id: products.id,
|
||||
customer_id: customers.id,
|
||||
fingerprint: customers.fingerprint,
|
||||
})
|
||||
.from(customerProducts)
|
||||
.innerJoin(
|
||||
products,
|
||||
eq(customerProducts.internal_product_id, products.internal_id),
|
||||
)
|
||||
.innerJoin(
|
||||
customers,
|
||||
eq(customerProducts.internal_customer_id, customers.internal_id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(customers.internal_id, fullCus.internal_id),
|
||||
fullCus.fingerprint
|
||||
? eq(customers.fingerprint, fullCus.fingerprint)
|
||||
: undefined,
|
||||
),
|
||||
eq(products.org_id, org.id),
|
||||
eq(products.env, env),
|
||||
isNotNull(customerProducts.trial_ends_at),
|
||||
),
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.customer_id !== null)
|
||||
.map((r) => ({
|
||||
plan_id: r.plan_id,
|
||||
customer_id: r.customer_id as string,
|
||||
fingerprint: r.fingerprint,
|
||||
}));
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import { batchUpdateCustomerProducts } from "./batchUpdateCustomerProducts";
|
||||
import { getByExternalIds } from "./getByExternalIds";
|
||||
import { fetchCustomerProductFreeTrials } from "./fetchCustomerProductFreeTrials";
|
||||
|
||||
export const customerProductRepo = {
|
||||
batchUpdate: batchUpdateCustomerProducts,
|
||||
getByExternalIds,
|
||||
batchUpdate: batchUpdateCustomerProducts,
|
||||
fetchFreeTrials: fetchCustomerProductFreeTrials,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CusService } from "../../CusService.js";
|
||||
import { getCusPaymentMethodRes } from "../cusResponseUtils/getCusPaymentMethodRes.js";
|
||||
import { getCusReferrals } from "../cusResponseUtils/getCusReferrals.js";
|
||||
import { getCusRewards } from "../cusResponseUtils/getCusRewards.js";
|
||||
import { getCusTrialsUsed } from "../cusResponseUtils/getCusTrialsUsed.js";
|
||||
|
||||
export const getApiCustomerExpand = async ({
|
||||
ctx,
|
||||
@@ -21,7 +22,7 @@ export const getApiCustomerExpand = async ({
|
||||
customerId?: string;
|
||||
fullCus?: FullCustomer;
|
||||
}): Promise<ApiCusExpand> => {
|
||||
const { org, env, db, logger, expand } = ctx;
|
||||
const { org, env, db, expand } = ctx;
|
||||
|
||||
// Filter out balances.feature and subscriptions.plan
|
||||
const filteredExpand = filterExpand({
|
||||
@@ -45,19 +46,6 @@ export const getApiCustomerExpand = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const getCusTrialsUsed = () => {
|
||||
if (expand.includes(CustomerExpand.TrialsUsed)) {
|
||||
return (
|
||||
fullCus.trials_used?.map((t) => ({
|
||||
plan_id: t.product_id,
|
||||
customer_id: t.customer_id,
|
||||
fingerprint: t.fingerprint,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getApiCusEntities = () => {
|
||||
if (expand.includes(CustomerExpand.Entities)) {
|
||||
return fullCus.entities.map((e) => ApiBaseEntitySchema.parse(e));
|
||||
@@ -67,7 +55,7 @@ export const getApiCustomerExpand = async ({
|
||||
|
||||
const cusExpand = expand as CustomerExpand[];
|
||||
|
||||
const [rewards, referrals, paymentMethod] = await Promise.all([
|
||||
const [rewards, referrals, paymentMethod, trialsUsed] = await Promise.all([
|
||||
getCusRewards({
|
||||
org,
|
||||
env,
|
||||
@@ -77,7 +65,6 @@ export const getApiCustomerExpand = async ({
|
||||
),
|
||||
expand: cusExpand,
|
||||
}),
|
||||
|
||||
getCusReferrals({
|
||||
db,
|
||||
fullCus,
|
||||
@@ -89,10 +76,15 @@ export const getApiCustomerExpand = async ({
|
||||
fullCus,
|
||||
expand: cusExpand,
|
||||
}),
|
||||
getCusTrialsUsed({
|
||||
ctx,
|
||||
fullCus,
|
||||
expand: cusExpand,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
trials_used: getCusTrialsUsed() ?? undefined,
|
||||
trials_used: trialsUsed ?? undefined,
|
||||
entities: getApiCusEntities() ?? undefined,
|
||||
rewards: rewards ?? undefined,
|
||||
// upcoming_invoice: upcomingInvoice,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CustomerExpand, type FullCustomer } from "@autumn/shared";
|
||||
import type { RepoContext } from "@/db/repoContext.js";
|
||||
import { customerProductRepo } from "../../cusProducts/repos/index.js";
|
||||
|
||||
export const getCusTrialsUsed = async ({
|
||||
ctx,
|
||||
fullCus,
|
||||
expand,
|
||||
}: {
|
||||
ctx: RepoContext;
|
||||
fullCus: FullCustomer;
|
||||
expand?: CustomerExpand[];
|
||||
}) => {
|
||||
if (!expand?.includes(CustomerExpand.TrialsUsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return customerProductRepo.fetchFreeTrials({ ctx, fullCus });
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Integration test: Free → Pro upgrade via Stripe Checkout session (Attach V1),
|
||||
* verifying the customer.products.updated webhook fires with the
|
||||
* correct scenario values.
|
||||
*
|
||||
* Does NOT use a pre-attached payment method. Instead, the upgrade
|
||||
* goes through the full Stripe Checkout flow via completeStripeCheckoutFormV2.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiProduct } from "@autumn/shared";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { timeout } from "@tests/utils/genUtils.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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
playToken = await generatePlayToken();
|
||||
console.log(`Generated Svix Play token: ${playToken}`);
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
const playUrl = getPlayWebhookUrl(playToken);
|
||||
console.log(`Creating Svix endpoint: ${playUrl}`);
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl });
|
||||
console.log(`Created Svix endpoint: ${endpointId}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) {
|
||||
await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
console.log(`Deleted Svix endpoint: ${endpointId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST: Free (default) → Pro via Stripe Checkout (V1)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test(
|
||||
`${chalk.yellowBright("webhook v1 checkout: free default → pro upgrade via stripe checkout - scenario: new")}`,
|
||||
async () => {
|
||||
const customerId = "webhook-v1-checkout-free-to-pro";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
const pro = products.pro({ id: "pro", items: [messagesItem] });
|
||||
|
||||
// Setup: customer with NO payment method and a free default product
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true, skipWebhooks: true }), // no payment method
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Step 1: Attach free default product via v1 (no checkout needed, it's free)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
// Wait for the "new" webhook for the free product attachment
|
||||
const freeWebhook = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (payload) =>
|
||||
payload.type === "customer.products.updated" &&
|
||||
payload.data?.customer?.id === customerId &&
|
||||
payload.data?.scenario === "new" &&
|
||||
payload.data?.updated_product?.id === free.id,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
expect(freeWebhook).not.toBeNull();
|
||||
expect(freeWebhook?.payload.data.scenario).toBe("new");
|
||||
expect(freeWebhook?.payload.data.updated_product.id).toBe(free.id);
|
||||
|
||||
// Verify free is active
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({ customer, productId: free.id });
|
||||
|
||||
// Step 2: Attempt upgrade to Pro via v1 — no payment method, so returns checkout_url
|
||||
const upgradeResult = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
expect(upgradeResult.checkout_url).toBeDefined();
|
||||
console.log(`Checkout URL: ${upgradeResult.checkout_url}`);
|
||||
|
||||
// Step 3: Complete the Stripe Checkout form via browser automation
|
||||
await completeStripeCheckoutFormV2({ url: upgradeResult.checkout_url });
|
||||
|
||||
// Wait for Stripe webhook to be processed by the server
|
||||
await timeout(12000);
|
||||
|
||||
// Step 4: Assert upgrade webhook received
|
||||
const upgradeWebhook = await waitForWebhook<CustomerProductsUpdatedPayload>(
|
||||
{
|
||||
token: playToken,
|
||||
predicate: (payload) =>
|
||||
payload.type === "customer.products.updated" &&
|
||||
payload.data?.customer?.id === customerId &&
|
||||
payload.data?.updated_product?.id === pro.id,
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(upgradeWebhook).not.toBeNull();
|
||||
expect(upgradeWebhook?.payload.type).toBe("customer.products.updated");
|
||||
|
||||
const { data } = upgradeWebhook!.payload;
|
||||
console.log(`Upgrade webhook scenario: ${data.scenario}`);
|
||||
expect(data.updated_product.id).toBe(pro.id);
|
||||
expect(data.customer.id).toBe(customerId);
|
||||
|
||||
// Step 5: Verify Pro is now active
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({ customer, productId: pro.id });
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Integration test: Free → Pro upgrade via Stripe Checkout session,
|
||||
* verifying the customer.products.updated webhook fires with the
|
||||
* correct scenario values.
|
||||
*
|
||||
* Does NOT use a pre-attached payment method. Instead, the upgrade
|
||||
* goes through the full Stripe Checkout flow via completeStripeCheckoutFormV2.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiProduct } from "@autumn/shared";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { timeout } from "@tests/utils/genUtils.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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
playToken = await generatePlayToken();
|
||||
console.log(`Generated Svix Play token: ${playToken}`);
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
const playUrl = getPlayWebhookUrl(playToken);
|
||||
console.log(`Creating Svix endpoint: ${playUrl}`);
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl });
|
||||
console.log(`Created Svix endpoint: ${endpointId}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) {
|
||||
await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
console.log(`Deleted Svix endpoint: ${endpointId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST: Free (default) → Pro via Stripe Checkout
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test(
|
||||
`${chalk.yellowBright("webhook checkout: free default → pro upgrade via stripe checkout - scenario: new")}`,
|
||||
async () => {
|
||||
const customerId = "webhook-checkout-free-to-pro";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
const pro = products.pro({ id: "pro", items: [messagesItem] });
|
||||
|
||||
// Setup: customer with NO payment method and a free default product
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: true, skipWebhooks: true }), // no payment method
|
||||
s.products({ list: [free, pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Step 1: Attach free default product (no checkout needed, it's free)
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
});
|
||||
|
||||
// Wait for the "new" webhook for the free product attachment
|
||||
const freeWebhook = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (payload) =>
|
||||
payload.type === "customer.products.updated" &&
|
||||
payload.data?.customer?.id === customerId &&
|
||||
payload.data?.scenario === "new" &&
|
||||
payload.data?.updated_product?.id === free.id,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
expect(freeWebhook).not.toBeNull();
|
||||
expect(freeWebhook?.payload.data.scenario).toBe("new");
|
||||
expect(freeWebhook?.payload.data.updated_product.id).toBe(free.id);
|
||||
|
||||
// Verify free is active
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({ customer, productId: free.id });
|
||||
|
||||
// Step 2: Attempt upgrade to Pro — no payment method, so returns payment_url
|
||||
const upgradeResult = await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
expect(upgradeResult.payment_url).toBeDefined();
|
||||
console.log(`Checkout URL: ${upgradeResult.payment_url}`);
|
||||
|
||||
// Step 3: Complete the Stripe Checkout form via browser automation
|
||||
await completeStripeCheckoutFormV2({ url: upgradeResult.payment_url });
|
||||
|
||||
// Wait for Stripe webhook to be processed by the server
|
||||
await timeout(12000);
|
||||
|
||||
// Step 4: Assert upgrade webhook received with scenario "new"
|
||||
// (free → pro upgrade goes through checkout session completed path)
|
||||
const upgradeWebhook = await waitForWebhook<CustomerProductsUpdatedPayload>(
|
||||
{
|
||||
token: playToken,
|
||||
predicate: (payload) =>
|
||||
payload.type === "customer.products.updated" &&
|
||||
payload.data?.customer?.id === customerId &&
|
||||
payload.data?.updated_product?.id === pro.id,
|
||||
timeoutMs: 20000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(upgradeWebhook).not.toBeNull();
|
||||
expect(upgradeWebhook?.payload.type).toBe("customer.products.updated");
|
||||
|
||||
const { data } = upgradeWebhook!.payload;
|
||||
console.log(`Upgrade webhook scenario: ${data.scenario}`);
|
||||
expect(data.updated_product.id).toBe(pro.id);
|
||||
expect(data.customer.id).toBe(customerId);
|
||||
|
||||
// Step 5: Verify Pro is now active
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({ customer, productId: pro.id });
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
Reference in New Issue
Block a user