Map Stripe subscription items to prepaid feature state

This commit is contained in:
Owen Greenhalgh
2026-04-29 15:58:10 +01:00
parent 7b070bd78c
commit 07fb7cc234
7 changed files with 653 additions and 95 deletions

View File

@@ -2,6 +2,7 @@ import type { SyncMappingV0 } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js";
import { sync } from "@/internal/billing/v2/actions/sync/sync.js";
import { findAutumnProductsForSubscription } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/findAutumnProductsForSubscription.js";
import { subscriptionToPrepaidFeatureOptions } from "@/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.js";
import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js";
export const autoSyncFromSubscription = async ({
@@ -12,7 +13,8 @@ export const autoSyncFromSubscription = async ({
subscriptionCreatedContext: StripeSubscriptionCreatedContext;
}) => {
const { logger } = ctx;
const { subscription, fullCustomer, candidateProducts } = subscriptionCreatedContext;
const { subscription, fullCustomer, candidateProducts } =
subscriptionCreatedContext;
const matchedProducts = findAutumnProductsForSubscription({
stripeSubscription: subscription,
@@ -38,10 +40,17 @@ export const autoSyncFromSubscription = async ({
}
const [matchedProduct] = matchedProducts;
const prepaidFeatureOptions = subscriptionToPrepaidFeatureOptions({
ctx,
stripeSubscription: subscription,
matchedProduct,
});
const mappings: SyncMappingV0[] = [
{
stripe_subscription_id: subscription.id,
plan_id: matchedProduct.id,
prepaid_feature_options: prepaidFeatureOptions,
expire_previous: true,
},
];

View File

@@ -178,10 +178,18 @@ const processSyncMapping = async ({
});
if (!feature) continue;
const prepaidFeatureOption = mapping.prepaid_feature_options?.find(
(featureOption) =>
featureOption.feature_id === feature.id ||
featureOption.internal_feature_id === feature.internal_id,
);
featureQuantities.push({
feature_id: feature.id,
internal_feature_id: feature.internal_id,
quantity: 0,
quantity: prepaidFeatureOption?.quantity ?? 0,
upcoming_quantity: prepaidFeatureOption?.upcoming_quantity,
adjustable_quantity: prepaidFeatureOption?.adjustable_quantity,
});
}

View File

@@ -0,0 +1,91 @@
import {
type FeatureOptions,
type FullProduct,
isAllocatedPrice,
isConsumablePrice,
isPrepaidPrice,
priceToEnt,
} from "@autumn/shared";
import type Stripe from "stripe";
import { stripeItemToFeatureOptionsQuantity } from "@/external/stripe/common/utils/stripeItemToFeatureOptionsQuantity.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { findSubscriptionItemForAutumnPrice } from "../autumnToStripe/findSubscriptionItemForAutumnPrice.js";
export const subscriptionToPrepaidFeatureOptions = ({
ctx,
stripeSubscription,
matchedProduct,
}: {
ctx: Pick<AutumnContext, "logger">;
stripeSubscription: Stripe.Subscription;
matchedProduct: FullProduct;
}): FeatureOptions[] => {
const prepaidFeatureOptions: FeatureOptions[] = [];
const stripeSubscriptionItems = stripeSubscription.items.data;
for (const price of matchedProduct.prices) {
if (isPrepaidPrice(price)) {
const entitlement = priceToEnt({
price,
entitlements: matchedProduct.entitlements,
});
if (!entitlement) {
ctx.logger.warn(
`sub.created auto-sync: prepaid price ${price.id} on product ${matchedProduct.id} has no matching entitlement; skipping quantity import`,
);
continue;
}
const stripeSubscriptionItem = findSubscriptionItemForAutumnPrice({
price,
product: matchedProduct,
stripeSubscriptionItems,
});
const quantity = stripeSubscriptionItem
? stripeItemToFeatureOptionsQuantity({
itemQuantity: stripeSubscriptionItem.quantity ?? 0,
price,
product: matchedProduct,
})
: 0;
if (!stripeSubscriptionItem) {
ctx.logger.warn(
`sub.created auto-sync: no Stripe subscription item matched prepaid price ${price.id} on product ${matchedProduct.id}; initializing quantity to 0`,
);
}
prepaidFeatureOptions.push({
feature_id: entitlement.feature.id,
internal_feature_id: entitlement.feature.internal_id,
quantity,
});
continue;
}
if (isConsumablePrice(price)) {
const stripeSubscriptionItem = findSubscriptionItemForAutumnPrice({
price,
product: matchedProduct,
stripeSubscriptionItems,
});
if (!stripeSubscriptionItem) {
ctx.logger.warn(
`sub.created auto-sync: no Stripe subscription item matched consumable price ${price.id} on product ${matchedProduct.id}`,
);
}
continue;
}
if (isAllocatedPrice(price)) {
ctx.logger.info(
`sub.created auto-sync: skipping allocated price ${price.id} on product ${matchedProduct.id}; allocated auto-sync is deferred`,
);
}
}
return prepaidFeatureOptions;
};

View File

@@ -0,0 +1,439 @@
import { test } from "bun:test";
import type { ApiCustomerV3, FullProduct, Price } 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";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import type Stripe from "stripe";
import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
import {
getStripeSandboxContext,
makeSubCreatedWebhookContext,
} from "./subscriptionCreatedTestUtils.js";
const testRunId = Date.now().toString(36);
const getFullProduct = async ({
ctx,
productId,
}: {
ctx: TestContext;
productId: string;
}) =>
ProductService.getFull({
db: ctx.db,
idOrInternalId: productId,
orgId: ctx.org.id,
env: ctx.env,
});
const getFeaturePrice = ({
fullProduct,
featureId,
}: {
fullProduct: FullProduct;
featureId: string;
}): Price => {
const price = fullProduct.prices.find(
(candidatePrice) =>
"feature_id" in candidatePrice.config &&
candidatePrice.config.feature_id === featureId,
);
if (!price) {
throw new Error(
`Product ${fullProduct.id} has no Stripe-backed price for feature ${featureId}`,
);
}
return price;
};
const getStripeSubscriptionPriceId = ({ price }: { price: Price }): string => {
const config = price.config;
const stripePriceId =
("stripe_prepaid_price_v2_id" in config &&
config.stripe_prepaid_price_v2_id) ||
config.stripe_price_id ||
config.stripe_empty_price_id;
if (!stripePriceId) {
throw new Error(`Price ${price.id} has no Stripe price ID`);
}
return stripePriceId;
};
const getStripeCustomerId = async ({
ctx,
customerId,
}: {
ctx: TestContext;
customerId: string;
}): Promise<string> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const stripeCustomerId = fullCustomer.processor?.id;
if (!stripeCustomerId) {
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
}
return stripeCustomerId;
};
const createStripeSubscription = async ({
ctx,
customerId,
subscriptionItems,
}: {
ctx: TestContext;
customerId: string;
subscriptionItems: Stripe.SubscriptionCreateParams.Item[];
}) => {
const stripeCustomerId = await getStripeCustomerId({ ctx, customerId });
return ctx.stripeCli.subscriptions.create({
customer: stripeCustomerId,
items: subscriptionItems,
});
};
const runSubscriptionCreatedAutoSync = async ({
ctx,
customerId,
stripeSubscription,
}: {
ctx: TestContext;
customerId: string;
stripeSubscription: Stripe.Subscription;
}) => {
await handleStripeSubscriptionCreated({
ctx: await makeSubCreatedWebhookContext({
ctx,
customerId,
stripeSubscription,
}),
});
};
test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: imports prepaid Stripe quantity")}`, async () => {
const ctx = await getStripeSandboxContext();
const customerId = `sub-created-auto-sync-prepaid-quantity-${testRunId}`;
const pro = products.pro({
id: "pro",
items: [
items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
}),
],
});
const { autumnV1 } = await initScenario({
customerId,
ctx,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const fullProduct = await getFullProduct({ ctx, productId: pro.id });
const messagesPrice = getFeaturePrice({
fullProduct,
featureId: TestFeature.Messages,
});
const messagesStripePriceId = getStripeSubscriptionPriceId({
price: messagesPrice,
});
const stripeSubscription = await createStripeSubscription({
ctx,
customerId,
subscriptionItems: [{ price: messagesStripePriceId, quantity: 5 }],
});
await runSubscriptionCreatedAutoSync({
ctx,
customerId,
stripeSubscription,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 500,
usage: 0,
});
});
test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: missing prepaid item initializes to zero quantity")}`, async () => {
const ctx = await getStripeSandboxContext();
const customerId = `sub-created-auto-sync-missing-prepaid-${testRunId}`;
const pro = products.pro({
id: "pro-missing-prepaid",
items: [
items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
}),
items.prepaidUsers({
includedUsage: 0,
billingUnits: 1,
}),
],
});
const { autumnV1 } = await initScenario({
customerId,
ctx,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const fullProduct = await getFullProduct({ ctx, productId: pro.id });
const messagesPrice = getFeaturePrice({
fullProduct,
featureId: TestFeature.Messages,
});
const messagesStripePriceId = getStripeSubscriptionPriceId({
price: messagesPrice,
});
const stripeSubscription = await createStripeSubscription({
ctx,
customerId,
subscriptionItems: [{ price: messagesStripePriceId, quantity: 3 }],
});
await runSubscriptionCreatedAutoSync({
ctx,
customerId,
stripeSubscription,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 300,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
balance: 0,
usage: 0,
});
});
test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: keeps consumable feature while seeding prepaid")}`, async () => {
const ctx = await getStripeSandboxContext();
const customerId = `sub-created-auto-sync-mixed-consumable-${testRunId}`;
const pro = products.pro({
id: "pro-mixed-consumable",
items: [
items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
}),
items.consumableWords({ includedUsage: 0 }),
],
});
const { autumnV1 } = await initScenario({
customerId,
ctx,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const fullProduct = await getFullProduct({ ctx, productId: pro.id });
const messagesStripePriceId = getStripeSubscriptionPriceId({
price: getFeaturePrice({
fullProduct,
featureId: TestFeature.Messages,
}),
});
const wordsStripePriceId = getStripeSubscriptionPriceId({
price: getFeaturePrice({
fullProduct,
featureId: TestFeature.Words,
}),
});
const stripeSubscription = await createStripeSubscription({
ctx,
customerId,
subscriptionItems: [
{ price: messagesStripePriceId, quantity: 2 },
{ price: wordsStripePriceId },
],
});
await runSubscriptionCreatedAutoSync({
ctx,
customerId,
stripeSubscription,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 0,
balance: 0,
usage: 0,
});
});
test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: skips allocated prices cleanly")}`, async () => {
const ctx = await getStripeSandboxContext();
const customerId = `sub-created-auto-sync-allocated-skip-${testRunId}`;
const pro = products.pro({
id: "pro-allocated-skip",
items: [
items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
}),
items.allocatedUsers({ includedUsage: 0 }),
],
});
const { autumnV1 } = await initScenario({
customerId,
ctx,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const fullProduct = await getFullProduct({ ctx, productId: pro.id });
const messagesStripePriceId = getStripeSubscriptionPriceId({
price: getFeaturePrice({
fullProduct,
featureId: TestFeature.Messages,
}),
});
const stripeSubscription = await createStripeSubscription({
ctx,
customerId,
subscriptionItems: [{ price: messagesStripePriceId, quantity: 1 }],
});
await runSubscriptionCreatedAutoSync({
ctx,
customerId,
stripeSubscription,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 100,
usage: 0,
});
});
test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: ignores extra unmapped Stripe items")}`, async () => {
const ctx = await getStripeSandboxContext();
const customerId = `sub-created-auto-sync-extra-stripe-item-${testRunId}`;
const pro = products.pro({
id: "pro-extra-stripe-item",
items: [
items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
}),
],
});
const { autumnV1 } = await initScenario({
customerId,
ctx,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const fullProduct = await getFullProduct({ ctx, productId: pro.id });
const messagesStripePriceId = getStripeSubscriptionPriceId({
price: getFeaturePrice({
fullProduct,
featureId: TestFeature.Messages,
}),
});
const extraStripeProduct = await ctx.stripeCli.products.create({
name: "Sub Created Auto Sync Extra Item",
});
const stripeSubscription = await createStripeSubscription({
ctx,
customerId,
subscriptionItems: [
{ price: messagesStripePriceId, quantity: 4 },
{
price_data: {
currency: "usd",
product: extraStripeProduct.id,
recurring: { interval: "month" },
unit_amount: 1234,
},
},
],
});
await runSubscriptionCreatedAutoSync({
ctx,
customerId,
stripeSubscription,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 400,
usage: 0,
});
});

View File

@@ -1,10 +1,5 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
AppEnv,
type FullCustomer,
organizations,
} from "@autumn/shared";
import { type ApiCustomerV3, AppEnv, type FullCustomer } from "@autumn/shared";
import {
createStripeSubscriptionFromProduct,
createStripeSubscriptionFromProducts,
@@ -17,66 +12,15 @@ import {
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import {
createTestContext,
type TestContext,
} from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
import { initDrizzle } from "@/db/initDrizzle";
import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import { OrgService } from "@/internal/orgs/OrgService";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache";
import { encryptData } from "@/utils/encryptUtils";
const ensureTestOrgUsesStripeSandboxKey = async (): Promise<TestContext> => {
const sandboxSecretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;
if (!sandboxSecretKey?.startsWith("sk_test_")) {
throw new Error(
"STRIPE_SANDBOX_SECRET_KEY must be set to a Stripe test-mode secret key",
);
}
const { db } = initDrizzle();
const orgSlug = process.env.TESTS_ORG;
if (!orgSlug) {
throw new Error("TESTS_ORG must be set before running integration tests");
}
const org = await OrgService.getBySlug({ db, slug: orgSlug });
if (!org) {
throw new Error(`Org with slug "${orgSlug}" not found`);
}
await db
.update(organizations)
.set({
stripe_connected: true,
stripe_config: {
...(org.stripe_config || {}),
test_api_key: encryptData(sandboxSecretKey),
},
test_stripe_connect: {},
})
.where(eq(organizations.id, org.id));
await clearOrgCache({
db,
orgId: org.id,
});
return createTestContext();
};
let stripeSandboxContext: Promise<TestContext> | undefined;
const getStripeSandboxContext = () => {
stripeSandboxContext ??= ensureTestOrgUsesStripeSandboxKey();
return stripeSandboxContext;
};
import {
getStripeSandboxContext,
makeSubCreatedWebhookContext,
} from "./subscriptionCreatedTestUtils.js";
const makeFullCustomer = ({
subscriptionIds = [],
@@ -141,37 +85,6 @@ const makeGuardrailContext = ({
};
};
const makeSubCreatedWebhookContext = async ({
ctx,
customerId,
stripeSubscription,
}: {
ctx: TestContext;
customerId: string;
stripeSubscription: Stripe.Subscription;
}): Promise<StripeWebhookContext> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
withSubs: true,
withEntities: true,
});
return {
...ctx,
fullCustomer,
stripeEvent: {
type: "customer.subscription.created",
data: {
object: {
id: stripeSubscription.id,
customer: fullCustomer.processor?.id,
},
},
} as Stripe.Event,
};
};
const withNodeEnv = async <T>(nodeEnv: string, callback: () => Promise<T>) => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = nodeEnv;

View File

@@ -0,0 +1,93 @@
import { organizations } from "@autumn/shared";
import {
createTestContext,
type TestContext,
} from "@tests/utils/testInitUtils/createTestContext";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
import { initDrizzle } from "@/db/initDrizzle";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import { OrgService } from "@/internal/orgs/OrgService";
import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache";
import { encryptData } from "@/utils/encryptUtils";
const ensureTestOrgUsesStripeSandboxKey = async (): Promise<TestContext> => {
const sandboxSecretKey = process.env.STRIPE_SANDBOX_SECRET_KEY;
if (!sandboxSecretKey?.startsWith("sk_test_")) {
throw new Error(
"STRIPE_SANDBOX_SECRET_KEY must be set to a Stripe test-mode secret key",
);
}
const { db } = initDrizzle();
const organizationSlug = process.env.TESTS_ORG;
if (!organizationSlug) {
throw new Error("TESTS_ORG must be set before running integration tests");
}
const organization = await OrgService.getBySlug({
db,
slug: organizationSlug,
});
if (!organization) {
throw new Error(`Org with slug "${organizationSlug}" not found`);
}
await db
.update(organizations)
.set({
stripe_connected: true,
stripe_config: {
...(organization.stripe_config || {}),
test_api_key: encryptData(sandboxSecretKey),
},
test_stripe_connect: {},
})
.where(eq(organizations.id, organization.id));
await clearOrgCache({
db,
orgId: organization.id,
});
return createTestContext();
};
let stripeSandboxContext: Promise<TestContext> | undefined;
export const getStripeSandboxContext = () => {
stripeSandboxContext ??= ensureTestOrgUsesStripeSandboxKey();
return stripeSandboxContext;
};
export const makeSubCreatedWebhookContext = async ({
ctx,
customerId,
stripeSubscription,
}: {
ctx: TestContext;
customerId: string;
stripeSubscription: Stripe.Subscription;
}): Promise<StripeWebhookContext> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
withSubs: true,
withEntities: true,
});
return {
...ctx,
fullCustomer,
stripeEvent: {
type: "customer.subscription.created",
data: {
object: {
id: stripeSubscription.id,
customer: fullCustomer.processor?.id,
},
},
} as Stripe.Event,
};
};

View File

@@ -1,10 +1,15 @@
import { z } from "zod/v4";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels.js";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels.js";
export const SyncMappingV0Schema = z.object({
stripe_subscription_id: z.string(),
plan_id: z.string(),
items: z.array(ProductItemSchema).optional(),
prepaid_feature_options: z
.array(FeatureOptionsSchema)
.optional()
.meta({ internal: true }),
expire_previous: z.boolean().optional(),
});