Map Stripe subscription items to prepaid feature state
This commit is contained in:
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user