diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts index 26bb7d9a9..20bfb967d 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts @@ -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, }, ]; diff --git a/server/src/internal/billing/v2/actions/sync/sync.ts b/server/src/internal/billing/v2/actions/sync/sync.ts index e400f518a..6531aa467 100644 --- a/server/src/internal/billing/v2/actions/sync/sync.ts +++ b/server/src/internal/billing/v2/actions/sync/sync.ts @@ -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, }); } diff --git a/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.ts b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.ts new file mode 100644 index 000000000..2046d7097 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/sync/stripeToAutumn/subscriptionToFeatureOptions.ts @@ -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; + 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; +}; diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts new file mode 100644 index 000000000..7e4dc6353 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts @@ -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 => { + 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(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(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(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(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(customerId); + await expectProductActive({ customer, productId: pro.id }); + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + balance: 400, + usage: 0, + }); +}); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts index 0ecd21e20..06cd86682 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts @@ -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 => { - 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 | 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 => { - 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 (nodeEnv: string, callback: () => Promise) => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = nodeEnv; diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts new file mode 100644 index 000000000..7b43c9c3a --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts @@ -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 => { + 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 | undefined; + +export const getStripeSandboxContext = () => { + stripeSandboxContext ??= ensureTestOrgUsesStripeSandboxKey(); + return stripeSandboxContext; +}; + +export const makeSubCreatedWebhookContext = async ({ + ctx, + customerId, + stripeSubscription, +}: { + ctx: TestContext; + customerId: string; + stripeSubscription: Stripe.Subscription; +}): Promise => { + 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, + }; +}; diff --git a/shared/api/billing/sync/syncParamsV0.ts b/shared/api/billing/sync/syncParamsV0.ts index ee5d11eb9..340811679 100644 --- a/shared/api/billing/sync/syncParamsV0.ts +++ b/shared/api/billing/sync/syncParamsV0.ts @@ -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(), });