Merge branch 'staging' of https://github.com/useautumn/autumn into staging
pull remote staging
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"migrate-functions": "infisical run --env=dev -- bun scripts/migrations/migrate-functions.ts",
|
||||
"migrate-functions:prod": "infisical run --env=prod -- bun scripts/migrations/migrate-functions.ts",
|
||||
"validate-schema": "infisical run --env=prod -- bun scripts/migrations/validate-schema.ts",
|
||||
"vite:build": "cd shared && bun ts && cd ../vite && bun run build",
|
||||
"setupci": "node scripts/setup/setupci.js",
|
||||
"replicate": "bun scripts/db/replicate.ts",
|
||||
"db:push": " bun -F @autumn/shared db:push",
|
||||
|
||||
@@ -56,6 +56,7 @@ local baseCustomer = {
|
||||
env = customerData.env,
|
||||
metadata = customerData.metadata,
|
||||
subscriptions = customerData.subscriptions,
|
||||
scheduled_subscriptions = customerData.scheduled_subscriptions,
|
||||
invoices = customerData.invoices,
|
||||
legacyData = customerData.legacyData,
|
||||
entities = customerData.entities,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
-- setSubscriptions.lua
|
||||
-- Updates only the subscriptions array in the customer cache
|
||||
-- Updates both subscriptions and scheduled_subscriptions arrays in the customer cache
|
||||
-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[])
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[])
|
||||
-- ARGV[3]: org_id
|
||||
-- ARGV[4]: env
|
||||
-- ARGV[5]: customer_id
|
||||
|
||||
local subscriptionsJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
local scheduledSubscriptionsJson = ARGV[2]
|
||||
local orgId = ARGV[3]
|
||||
local env = ARGV[4]
|
||||
local customerId = ARGV[5]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildCustomerCacheKey(orgId, env, customerId)
|
||||
@@ -23,9 +25,11 @@ end
|
||||
-- Decode the base customer and subscriptions
|
||||
local baseCustomer = cjson.decode(baseJson)
|
||||
local subscriptions = cjson.decode(subscriptionsJson)
|
||||
local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson)
|
||||
|
||||
-- Update the subscriptions field
|
||||
-- Update the subscriptions fields
|
||||
baseCustomer.subscriptions = subscriptions
|
||||
baseCustomer.scheduled_subscriptions = scheduledSubscriptions
|
||||
|
||||
-- Store updated base customer as JSON and extend TTL
|
||||
redis.call("SET", baseKey, cjson.encode(baseCustomer))
|
||||
|
||||
@@ -41,6 +41,7 @@ for _, entityWrapper in ipairs(entities) do
|
||||
created_at = entityData.created_at,
|
||||
env = entityData.env,
|
||||
subscriptions = entityData.subscriptions,
|
||||
scheduled_subscriptions = entityData.scheduled_subscriptions,
|
||||
legacyData = entityData.legacyData,
|
||||
_balanceFeatureIds = balanceFeatureIds
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ local baseEntity = {
|
||||
created_at = entityData.created_at,
|
||||
env = entityData.env,
|
||||
subscriptions = entityData.subscriptions,
|
||||
scheduled_subscriptions = entityData.scheduled_subscriptions,
|
||||
legacyData = entityData.legacyData,
|
||||
_balanceFeatureIds = balanceFeatureIds
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
-- setEntityProducts.lua
|
||||
-- Updates only the products array in the entity cache
|
||||
-- ARGV[1]: serialized products array JSON string
|
||||
-- ARGV[2]: org_id
|
||||
-- ARGV[3]: env
|
||||
-- ARGV[4]: customer_id
|
||||
-- ARGV[5]: entity_id
|
||||
-- Updates both subscriptions and scheduled_subscriptions arrays in the entity cache
|
||||
-- ARGV[1]: serialized subscriptions array JSON string (ApiSubscription[])
|
||||
-- ARGV[2]: serialized scheduled_subscriptions array JSON string (ApiSubscription[])
|
||||
-- ARGV[3]: org_id
|
||||
-- ARGV[4]: env
|
||||
-- ARGV[5]: customer_id
|
||||
-- ARGV[6]: entity_id
|
||||
|
||||
local productsJson = ARGV[1]
|
||||
local orgId = ARGV[2]
|
||||
local env = ARGV[3]
|
||||
local customerId = ARGV[4]
|
||||
local entityId = ARGV[5]
|
||||
local subscriptionsJson = ARGV[1]
|
||||
local scheduledSubscriptionsJson = ARGV[2]
|
||||
local orgId = ARGV[3]
|
||||
local env = ARGV[4]
|
||||
local customerId = ARGV[5]
|
||||
local entityId = ARGV[6]
|
||||
|
||||
-- Build versioned cache key using shared utility
|
||||
local cacheKey = buildEntityCacheKey(orgId, env, customerId, entityId)
|
||||
@@ -22,12 +24,14 @@ if not baseJson then
|
||||
return "OK" -- Entity doesn't exist, return early
|
||||
end
|
||||
|
||||
-- Decode the base entity and products
|
||||
-- Decode the base entity and subscriptions
|
||||
local baseEntity = cjson.decode(baseJson)
|
||||
local products = cjson.decode(productsJson)
|
||||
local subscriptions = cjson.decode(subscriptionsJson)
|
||||
local scheduledSubscriptions = cjson.decode(scheduledSubscriptionsJson)
|
||||
|
||||
-- Update only the products array
|
||||
baseEntity.products = products
|
||||
-- Update the subscriptions fields
|
||||
baseEntity.subscriptions = subscriptions
|
||||
baseEntity.scheduled_subscriptions = scheduledSubscriptions
|
||||
|
||||
-- Store updated base entity as JSON and extend TTL
|
||||
redis.call("SET", baseKey, cjson.encode(baseEntity))
|
||||
|
||||
@@ -73,6 +73,27 @@ local function getCustomerObject(orgId, env, customerId, skipEntityMerge)
|
||||
|
||||
-- Merge subscriptions by plan ID and normalized status
|
||||
baseCustomer.subscriptions = mergeSubscriptions(allSubscriptions)
|
||||
|
||||
-- Collect all scheduled_subscriptions: start with customer's scheduled_subscriptions, then add all entity scheduled_subscriptions
|
||||
local allScheduledSubscriptions = {}
|
||||
if baseCustomer.scheduled_subscriptions then
|
||||
for _, subscription in ipairs(baseCustomer.scheduled_subscriptions) do
|
||||
table.insert(allScheduledSubscriptions, subscription)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add scheduled_subscriptions from each entity
|
||||
for _, entityId in ipairs(entityIds) do
|
||||
local entityBase = entityBaseData[entityId]
|
||||
if entityBase and entityBase.scheduled_subscriptions then
|
||||
for _, subscription in ipairs(entityBase.scheduled_subscriptions) do
|
||||
table.insert(allScheduledSubscriptions, subscription)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Merge scheduled_subscriptions by plan ID and normalized status
|
||||
baseCustomer.scheduled_subscriptions = mergeSubscriptions(allScheduledSubscriptions)
|
||||
|
||||
-- Merge invoices
|
||||
-- Build final customer object
|
||||
@@ -135,21 +156,26 @@ local function getEntityObject(orgId, env, customerId, entityId, skipCustomerMer
|
||||
|
||||
-- Get entity subscriptions (start with entity's own subscriptions)
|
||||
local entitySubscriptions = baseEntity.subscriptions or {}
|
||||
local entityScheduledSubscriptions = baseEntity.scheduled_subscriptions or {}
|
||||
|
||||
if not skipCustomerMerge then
|
||||
-- Get customer subscriptions
|
||||
-- Get customer subscriptions and scheduled_subscriptions
|
||||
local customerSubscriptions = nil
|
||||
local customerScheduledSubscriptions = nil
|
||||
local customerBaseJson = redis.call("GET", customerCacheKey)
|
||||
if customerBaseJson then
|
||||
local customerBase = cjson.decode(customerBaseJson)
|
||||
customerSubscriptions = customerBase.subscriptions
|
||||
customerScheduledSubscriptions = customerBase.scheduled_subscriptions
|
||||
end
|
||||
|
||||
-- Merge customer subscriptions into entity subscriptions (only add if not exists)
|
||||
baseEntity.subscriptions = mergeCustomerSubscriptionsIntoEntity(entitySubscriptions, customerSubscriptions)
|
||||
baseEntity.scheduled_subscriptions = mergeCustomerSubscriptionsIntoEntity(entityScheduledSubscriptions, customerScheduledSubscriptions)
|
||||
else
|
||||
-- No merging - just use entity's own subscriptions
|
||||
baseEntity.subscriptions = entitySubscriptions
|
||||
baseEntity.scheduled_subscriptions = entityScheduledSubscriptions
|
||||
end
|
||||
|
||||
-- Build final entity object
|
||||
|
||||
4
server/src/external/redis/initRedis.ts
vendored
4
server/src/external/redis/initRedis.ts
vendored
@@ -135,12 +135,14 @@ declare module "ioredis" {
|
||||
): Promise<string>;
|
||||
setSubscriptions(
|
||||
subscriptionsJson: string,
|
||||
scheduledSubscriptionsJson: string,
|
||||
orgId: string,
|
||||
env: string,
|
||||
customerId: string,
|
||||
): Promise<string>;
|
||||
setEntityProducts(
|
||||
productsJson: string,
|
||||
subscriptionsJson: string,
|
||||
scheduledSubscriptionsJson: string,
|
||||
orgId: string,
|
||||
env: string,
|
||||
customerId: string,
|
||||
|
||||
@@ -19,7 +19,10 @@ import { findPrepaidPrice } from "@/internal/products/prices/priceUtils/findPric
|
||||
|
||||
import { formatAmount } from "@/utils/formatUtils.js";
|
||||
import { sortProductsByPrice } from "../../../internal/products/productUtils/sortProductUtils.js";
|
||||
import { isOneOff } from "../../../internal/products/productUtils.js";
|
||||
import {
|
||||
isFreeProduct,
|
||||
isOneOff,
|
||||
} from "../../../internal/products/productUtils.js";
|
||||
import type { VercelBillingPlan } from "../misc/vercelTypes.js";
|
||||
|
||||
/**
|
||||
@@ -157,12 +160,13 @@ export const listVercelPlansForOrg = async ({
|
||||
|
||||
// 1. Get rid of products that have usage prices
|
||||
// 2. Get rid of products that are archived
|
||||
// 3. Get rid of products that are one off, only if they are not free
|
||||
const filteredProducts = products
|
||||
.filter((p) => !p.prices.some((price) => isUsagePrice({ price })))
|
||||
.filter(
|
||||
(p) =>
|
||||
!p.is_add_on &&
|
||||
!isOneOff(p.prices) &&
|
||||
(!isOneOff(p.prices) || isFreeProduct(p.prices)) &&
|
||||
!p.archived &&
|
||||
(p.entitlements.length > 0 || p.is_default),
|
||||
);
|
||||
|
||||
@@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({
|
||||
}),
|
||||
orgCurrency: ctx.org.default_currency ?? "usd",
|
||||
})
|
||||
: null,
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return c.json(installation, 200);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { AppEnv, CusExpand, RecaseError } from "@autumn/shared";
|
||||
import {
|
||||
AppEnv,
|
||||
CusExpand,
|
||||
type FullProduct,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { ErrCode } from "@shared/enums/ErrCode.js";
|
||||
import { DrizzleError } from "drizzle-orm";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { z } from "zod/v4";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { createStripeCli } from "@/external/connect/createStripeCli.js";
|
||||
import { sendCustomSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import { createVercelSubscription } from "@/external/vercel/misc/vercelSubscriptions.js";
|
||||
@@ -73,37 +80,50 @@ export const handleCreateResource = createRoute({
|
||||
// 2. Create resource in database (enforces 1-resource limit)
|
||||
const resourceId = generateId("vre");
|
||||
|
||||
await VercelResourceService.create({
|
||||
db,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
org_id: orgId,
|
||||
env: env as AppEnv,
|
||||
installation_id: integrationConfigurationId,
|
||||
name,
|
||||
status: "pending",
|
||||
metadata: metadata ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Create subscription (installation-level billing)
|
||||
const { product } = await createVercelSubscription({
|
||||
db,
|
||||
org,
|
||||
env: env as AppEnv,
|
||||
customer,
|
||||
stripeCustomer,
|
||||
stripeCli,
|
||||
integrationConfigurationId,
|
||||
billingPlanId,
|
||||
features,
|
||||
logger,
|
||||
c,
|
||||
metadata,
|
||||
resourceId,
|
||||
});
|
||||
|
||||
try {
|
||||
const product = await db.transaction(async (tx) => {
|
||||
await VercelResourceService.create({
|
||||
db: tx as unknown as DrizzleCli,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
org_id: orgId,
|
||||
env: env as AppEnv,
|
||||
installation_id: integrationConfigurationId,
|
||||
name,
|
||||
status: "pending",
|
||||
metadata: metadata ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
let createdProduct: FullProduct;
|
||||
|
||||
try {
|
||||
// 3. Create subscription (installation-level billing)
|
||||
const { product } = await createVercelSubscription({
|
||||
db: tx as unknown as DrizzleCli,
|
||||
org,
|
||||
env: env as AppEnv,
|
||||
customer,
|
||||
stripeCustomer,
|
||||
stripeCli,
|
||||
integrationConfigurationId,
|
||||
billingPlanId,
|
||||
features,
|
||||
logger,
|
||||
c,
|
||||
metadata,
|
||||
resourceId,
|
||||
});
|
||||
|
||||
createdProduct = product;
|
||||
} catch (error) {
|
||||
tx.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return createdProduct;
|
||||
});
|
||||
|
||||
await sendCustomSvixEvent({
|
||||
appId:
|
||||
org.processor_configs?.vercel?.svix?.[
|
||||
@@ -121,27 +141,46 @@ export const handleCreateResource = createRoute({
|
||||
access_token: customer.processors?.vercel?.access_token ?? "",
|
||||
} satisfies VercelResourceCreatedEvent,
|
||||
});
|
||||
} catch (_error) {}
|
||||
// 4. Return resource response
|
||||
return c.json({
|
||||
id: resourceId,
|
||||
productId,
|
||||
name,
|
||||
metadata,
|
||||
status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment
|
||||
billingPlan: {
|
||||
...productToBillingPlan({
|
||||
product,
|
||||
orgCurrency: org?.default_currency ?? "usd",
|
||||
}),
|
||||
scope: "installation", // Always installation-level
|
||||
},
|
||||
secrets: [],
|
||||
notification: {
|
||||
level: "info",
|
||||
title: "Resource provisioning",
|
||||
message: `Setting up ${name}...`,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Return resource response
|
||||
return c.json({
|
||||
id: resourceId,
|
||||
productId,
|
||||
name,
|
||||
metadata,
|
||||
status: "pending", // Will become "ready" after marketplace.invoice.paid confirms payment
|
||||
billingPlan: {
|
||||
...productToBillingPlan({
|
||||
product,
|
||||
orgCurrency: org?.default_currency ?? "usd",
|
||||
}),
|
||||
scope: "installation", // Always installation-level
|
||||
},
|
||||
secrets: [],
|
||||
notification: {
|
||||
level: "info",
|
||||
title: "Resource provisioning",
|
||||
message: `Setting up ${name}...`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
code: "conflict",
|
||||
message:
|
||||
error instanceof DrizzleError
|
||||
? error.message.includes("Rollback")
|
||||
? "An error occurred while creating the resource's subscription"
|
||||
: error.message
|
||||
: error instanceof RecaseError
|
||||
? error.message
|
||||
: "An error occurred while creating the resource",
|
||||
user: null,
|
||||
},
|
||||
},
|
||||
StatusCodes.CONFLICT,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@ import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
|
||||
import { getStripeSubItems2 } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { createStripeSub2 } from "@/internal/customers/attach/attachFunctions/addProductFlow/createStripeSub2.js";
|
||||
import { handleFreeProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { isFreeProduct } from "@/internal/products/productUtils.js";
|
||||
import {
|
||||
getVercelAttachBody,
|
||||
parseVercelPrepaidQuantities,
|
||||
@@ -64,7 +67,7 @@ export const createVercelSubscription = async ({
|
||||
c: Context<HonoEnv>;
|
||||
metadata?: Record<string, any>;
|
||||
resourceId?: string;
|
||||
}): Promise<{ subscription: Stripe.Subscription; product: FullProduct }> => {
|
||||
}): Promise<{ product: FullProduct }> => {
|
||||
// 1. Check for existing non-incomplete subscription (only allow one per installation)
|
||||
const existingSubscription = stripeCustomer.subscriptions?.data.find(
|
||||
(s) =>
|
||||
@@ -101,6 +104,13 @@ export const createVercelSubscription = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const refreshedCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customer.internal_id,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
// 3. Get custom payment method (created in handleUpsertInstallation)
|
||||
const customPaymentMethod = await getCusPaymentMethod({
|
||||
stripeCli,
|
||||
@@ -148,21 +158,34 @@ export const createVercelSubscription = async ({
|
||||
resourceId,
|
||||
});
|
||||
|
||||
// 6. Get subscription items
|
||||
const itemSet = await getStripeSubItems2({
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
if (isFreeProduct(product.prices)) {
|
||||
if (
|
||||
!refreshedCustomer?.customer_products.find(
|
||||
(cp) => cp.product_id === billingPlanId,
|
||||
)
|
||||
) {
|
||||
await handleFreeProduct({
|
||||
ctx: c.get("ctx"),
|
||||
attachParams,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 6. Get subscription items
|
||||
const itemSet = await getStripeSubItems2({
|
||||
attachParams,
|
||||
config,
|
||||
});
|
||||
|
||||
// 7. Create Stripe subscription
|
||||
const subscription = await createStripeSub2({
|
||||
db,
|
||||
stripeCli,
|
||||
attachParams,
|
||||
config,
|
||||
itemSet,
|
||||
logger,
|
||||
});
|
||||
// 7. Create Stripe subscription
|
||||
await createStripeSub2({
|
||||
db,
|
||||
stripeCli,
|
||||
attachParams,
|
||||
config,
|
||||
itemSet,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// Subscription will be 'incomplete' initially with an 'open' invoice
|
||||
// Payment flow:
|
||||
@@ -174,5 +197,5 @@ export const createVercelSubscription = async ({
|
||||
// - Attaches payment record to invoice
|
||||
// 4. Invoice becomes 'paid' → Subscription becomes 'active'
|
||||
|
||||
return { subscription, product };
|
||||
return { product };
|
||||
};
|
||||
|
||||
@@ -139,7 +139,11 @@ export const handleProductsUpdated = async ({
|
||||
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
|
||||
addToExpand({
|
||||
ctx,
|
||||
add: [CusExpand.BalancesFeature, CusExpand.SubscriptionsPlan],
|
||||
add: [
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SuccessCode,
|
||||
} from "@autumn/shared";
|
||||
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { attachToInsertParams } from "@/internal/products/productUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { createFullCusProduct } from "../../../add-product/createFullCusProduct.js";
|
||||
@@ -108,3 +109,64 @@ export const handleAddProduct = async ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFreeProduct = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
config,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
config?: AttachConfig;
|
||||
}) => {
|
||||
const { logger } = ctx;
|
||||
const { products, prices } = attachParams;
|
||||
|
||||
const defaultConfig: AttachConfig = getDefaultAttachConfig();
|
||||
|
||||
// 1. If paid product
|
||||
|
||||
if (prices.length < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Inserting free product in handleFreeProduct");
|
||||
|
||||
const batchInsert = [];
|
||||
|
||||
const { mergeSub } = await getMergeCusProduct({
|
||||
attachParams,
|
||||
config: config || defaultConfig,
|
||||
products,
|
||||
});
|
||||
|
||||
for (const product of products) {
|
||||
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
|
||||
let anchorToUnix;
|
||||
|
||||
if (curCusProduct && config?.branch === AttachBranch.NewVersion) {
|
||||
anchorToUnix = curCusProduct.created_at;
|
||||
}
|
||||
|
||||
if (mergeSub) {
|
||||
const { end } = subToPeriodStartEnd({ sub: mergeSub });
|
||||
anchorToUnix = end * 1000;
|
||||
}
|
||||
|
||||
// Expire previous product
|
||||
|
||||
batchInsert.push(
|
||||
createFullCusProduct({
|
||||
db: ctx.db,
|
||||
attachParams: attachToInsertParams(attachParams, product),
|
||||
billLaterOnly: true,
|
||||
carryExistingUsages: config?.carryUsage || false,
|
||||
anchorToUnix,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(batchInsert);
|
||||
|
||||
logger.info("Successfully created full cus product");
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export const setCachedApiCustomer = async ({
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.Invoices,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import { getApiSubscriptions } from "../apiCusUtils/getApiSubscription/getApiSub
|
||||
|
||||
/**
|
||||
* Set customer subscriptions cache in Redis with all entities
|
||||
* This function updates only the subscriptions array in the customer cache (customer-level subscriptions only)
|
||||
* and individual entity caches (entity-level subscriptions only)
|
||||
* This function updates subscriptions and scheduled_subscriptions arrays in the customer cache (customer-level only)
|
||||
* and individual entity caches (entity-level only)
|
||||
*/
|
||||
export const setCachedApiSubs = async ({
|
||||
ctx,
|
||||
@@ -29,7 +29,7 @@ export const setCachedApiSubs = async ({
|
||||
// Build master api customer subscriptions (customer-level products only)
|
||||
const ctxWithExpand = addToExpand({
|
||||
ctx,
|
||||
add: [CusExpand.SubscriptionsPlan],
|
||||
add: [CusExpand.SubscriptionsPlan, CusExpand.ScheduledSubscriptionsPlan],
|
||||
});
|
||||
const { data: masterApiSubs } = await getApiSubscriptions({
|
||||
ctx: ctxWithExpand,
|
||||
@@ -41,19 +41,28 @@ export const setCachedApiSubs = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// Split subscriptions by status
|
||||
const activeSubscriptions = masterApiSubs.filter(
|
||||
(s) => s.status === "active",
|
||||
);
|
||||
const scheduledSubscriptions = masterApiSubs.filter(
|
||||
(s) => s.status === "scheduled",
|
||||
);
|
||||
|
||||
// console.log(`Updating api subs for customer ${customerId}`, masterApiSubs);
|
||||
|
||||
// Then write to Redis
|
||||
await tryRedisWrite(async () => {
|
||||
// Update customer subscriptions
|
||||
// Update customer subscriptions and scheduled_subscriptions
|
||||
await redis.setSubscriptions(
|
||||
JSON.stringify(masterApiSubs),
|
||||
JSON.stringify(activeSubscriptions),
|
||||
JSON.stringify(scheduledSubscriptions),
|
||||
org.id,
|
||||
env,
|
||||
customerId,
|
||||
);
|
||||
logger.info(
|
||||
`Updated customer subscriptions cache for customer ${customerId} (${masterApiSubs.length} subscriptions)`,
|
||||
`Updated customer subscriptions cache for customer ${customerId} (${activeSubscriptions.length} active, ${scheduledSubscriptions.length} scheduled)`,
|
||||
);
|
||||
|
||||
// Update entity subscriptions
|
||||
@@ -65,7 +74,7 @@ export const setCachedApiSubs = async ({
|
||||
org,
|
||||
});
|
||||
|
||||
const { data: entityProducts } = await getApiSubscriptions({
|
||||
const { data: entitySubscriptions } = await getApiSubscriptions({
|
||||
ctx: ctxWithExpand,
|
||||
fullCus: {
|
||||
...fullCus,
|
||||
@@ -74,15 +83,24 @@ export const setCachedApiSubs = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// Split entity subscriptions by status
|
||||
const entityActiveSubscriptions = entitySubscriptions.filter(
|
||||
(s) => s.status === "active",
|
||||
);
|
||||
const entityScheduledSubscriptions = entitySubscriptions.filter(
|
||||
(s) => s.status === "scheduled",
|
||||
);
|
||||
|
||||
await redis.setEntityProducts(
|
||||
JSON.stringify(entityProducts),
|
||||
JSON.stringify(entityActiveSubscriptions),
|
||||
JSON.stringify(entityScheduledSubscriptions),
|
||||
org.id,
|
||||
env,
|
||||
customerId,
|
||||
entity.id,
|
||||
);
|
||||
logger.info(
|
||||
`Updated entity subscriptions cache for entity ${entity.id} (${entityProducts.length} subscriptions)`,
|
||||
`Updated entity subscriptions cache for entity ${entity.id} (${entityActiveSubscriptions.length} active, ${entityScheduledSubscriptions.length} scheduled)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +52,12 @@ export const getApiCustomerBase = async ({
|
||||
env: fullCus.env,
|
||||
metadata: fullCus.metadata,
|
||||
|
||||
subscriptions: apiSubscriptions,
|
||||
// subscriptions: apiSubscriptions,
|
||||
subscriptions: apiSubscriptions.filter((s) => s.status === "active"),
|
||||
scheduled_subscriptions: apiSubscriptions.filter(
|
||||
(s) => s.status === "scheduled",
|
||||
),
|
||||
|
||||
balances: apiBalances,
|
||||
|
||||
invoices:
|
||||
|
||||
@@ -29,6 +29,7 @@ export const getApiCustomerExpand = async ({
|
||||
filter: [
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
CusExpand.Invoices,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CusProductStatus,
|
||||
cusProductToPlanStatus,
|
||||
cusProductToProduct,
|
||||
expandIncludes,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
isTrialing,
|
||||
@@ -61,9 +62,17 @@ export const getApiSubscription = async ({
|
||||
const status = cusProductToPlanStatus({ status: cusProduct.status });
|
||||
|
||||
// Check if we should expand the plan object
|
||||
const shouldExpandPlan = (ctx.expand ?? []).includes(
|
||||
CusExpand.SubscriptionsPlan,
|
||||
);
|
||||
|
||||
const shouldExpandPlan =
|
||||
status === "scheduled"
|
||||
? expandIncludes({
|
||||
expand: ctx.expand,
|
||||
includes: [CusExpand.ScheduledSubscriptionsPlan],
|
||||
})
|
||||
: expandIncludes({
|
||||
expand: ctx.expand,
|
||||
includes: [CusExpand.SubscriptionsPlan],
|
||||
});
|
||||
|
||||
const apiPlan = shouldExpandPlan
|
||||
? await getPlanResponse({
|
||||
|
||||
@@ -25,7 +25,7 @@ export const handleDecreaseAndTransfer = async ({
|
||||
ctx: AutumnContext;
|
||||
fullCus: FullCustomer;
|
||||
cusProduct: FullCusProduct;
|
||||
toEntity: Entity;
|
||||
toEntity?: Entity | null;
|
||||
}) => {
|
||||
// 1. Create new cus product for entity...
|
||||
const { org, env, db, logger, features } = ctx;
|
||||
@@ -90,8 +90,8 @@ export const handleDecreaseAndTransfer = async ({
|
||||
replaceables: [],
|
||||
entities: fullCus.entities,
|
||||
features,
|
||||
internalEntityId: toEntity.internal_id,
|
||||
entityId: toEntity.id,
|
||||
internalEntityId: toEntity?.internal_id ? toEntity.internal_id : undefined,
|
||||
entityId: toEntity?.id ? toEntity.id : undefined,
|
||||
},
|
||||
product,
|
||||
),
|
||||
|
||||
@@ -17,10 +17,14 @@ import { handleDecreaseAndTransfer } from "./handleTransferProduct/handleDecreas
|
||||
|
||||
const TransferProductSchema = z.object({
|
||||
from_entity_id: z.string().nullish(),
|
||||
to_entity_id: z.string(),
|
||||
to_entity_id: z.string().nullish(),
|
||||
product_id: z.string(),
|
||||
});
|
||||
|
||||
// Supports:
|
||||
// - Transfer from entity to entity
|
||||
// - Transfer from entity to org
|
||||
// - Transfer from org to entity
|
||||
export const handleTransferProductV2 = createRoute({
|
||||
body: TransferProductSchema,
|
||||
resource: AffectedResource.Customer,
|
||||
@@ -30,6 +34,12 @@ export const handleTransferProductV2 = createRoute({
|
||||
const { customer_id } = c.req.param();
|
||||
const { from_entity_id, to_entity_id, product_id } = c.req.valid("json");
|
||||
|
||||
if(!from_entity_id && !to_entity_id) {
|
||||
throw new RecaseError({
|
||||
message: "Must specify atleast one of: from_entity_id, to_entity_id",
|
||||
});
|
||||
}
|
||||
|
||||
const customer = await CusService.getFull({
|
||||
idOrInternalId: customer_id,
|
||||
orgId: org.id,
|
||||
@@ -56,9 +66,11 @@ export const handleTransferProductV2 = createRoute({
|
||||
(e: any) => e.id === from_entity_id,
|
||||
);
|
||||
|
||||
const toEntity = customer.entities.find((e: any) => e.id === to_entity_id);
|
||||
const toEntity = to_entity_id
|
||||
? customer.entities.find((e: any) => e.id === to_entity_id)
|
||||
: null;
|
||||
|
||||
if (!toEntity) {
|
||||
if (to_entity_id && !toEntity) {
|
||||
throw new RecaseError({
|
||||
message: `Entity ${to_entity_id} not found`,
|
||||
});
|
||||
@@ -73,14 +85,15 @@ export const handleTransferProductV2 = createRoute({
|
||||
|
||||
const toCusProduct = customer.customer_products.find(
|
||||
(cp: any) =>
|
||||
cp.internal_entity_id === toEntity.internal_id &&
|
||||
cp.internal_entity_id === (toEntity?.internal_id || null) &&
|
||||
cp.product.group === product.group,
|
||||
);
|
||||
|
||||
if (toCusProduct) {
|
||||
throw new CusProductAlreadyExistsError({
|
||||
productId: product_id,
|
||||
entityId: toEntity.id,
|
||||
entityId: toEntity?.id,
|
||||
customerId: (from_entity_id && !to_entity_id) ? customer_id : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,8 +118,8 @@ export const handleTransferProductV2 = createRoute({
|
||||
db,
|
||||
cusProductId: cusProduct.id,
|
||||
updates: {
|
||||
entity_id: toEntity.id,
|
||||
internal_entity_id: toEntity.internal_id,
|
||||
entity_id: toEntity?.id || null,
|
||||
internal_entity_id: toEntity?.internal_id || null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,8 +132,8 @@ export const handleTransferProductV2 = createRoute({
|
||||
scenario: AttachScenario.New,
|
||||
cusProduct: {
|
||||
...cusProduct,
|
||||
entity_id: toEntity.id,
|
||||
internal_entity_id: toEntity.internal_id,
|
||||
entity_id: toEntity?.id || null,
|
||||
internal_entity_id: toEntity?.internal_id || null,
|
||||
},
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
@@ -66,7 +66,10 @@ export const getApiEntityBase = async ({
|
||||
created_at: entity.created_at,
|
||||
env: fullCus.env,
|
||||
|
||||
subscriptions: apiSubscriptions,
|
||||
subscriptions: apiSubscriptions.filter((s) => s.status === "active"),
|
||||
scheduled_subscriptions: apiSubscriptions.filter(
|
||||
(s) => s.status === "scheduled",
|
||||
),
|
||||
balances: apiBalances,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const handleGetEntity = createRoute({
|
||||
entityId: entity_id,
|
||||
withAutumnId: with_autumn_id,
|
||||
});
|
||||
|
||||
const duration = Date.now() - start;
|
||||
ctx.logger.debug(`[get-entity] duration: ${duration}ms`);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export const handleGetOAuthUrl = createRoute({
|
||||
serverUrl = `https://express.dev.useautumn.com`;
|
||||
}
|
||||
|
||||
|
||||
// Add state + redirect_uri
|
||||
baseUrl.searchParams.set("state", stateKey);
|
||||
baseUrl.searchParams.set(
|
||||
|
||||
@@ -156,10 +156,18 @@ export const normalizeFromSchema = <T>({
|
||||
};
|
||||
|
||||
for (const key in shape) {
|
||||
normalized[key] = normalizeFromSchema({
|
||||
schema: shape[key],
|
||||
data: normalized[key],
|
||||
});
|
||||
// Hardcoded fix: scheduled_subscriptions should be an array, never null/undefined
|
||||
if (
|
||||
key === "scheduled_subscriptions" &&
|
||||
(normalized[key] === undefined || normalized[key] === null)
|
||||
) {
|
||||
normalized[key] = [];
|
||||
} else {
|
||||
normalized[key] = normalizeFromSchema({
|
||||
schema: shape[key],
|
||||
data: normalized[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalized as T;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import type Stripe from "stripe";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import {
|
||||
@@ -9,6 +7,8 @@ import {
|
||||
expectNextCycleCorrect,
|
||||
} from "@tests/utils/expectUtils/expectScheduleUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
|
||||
@@ -13,6 +13,10 @@ import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import {
|
||||
expectProductAttached,
|
||||
expectScheduledApiSub,
|
||||
} from "../../utils/expectUtils/expectProductAttached";
|
||||
|
||||
const testCase = "aentity5";
|
||||
|
||||
@@ -111,10 +115,24 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing downgrade entity pro
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, entity1.id);
|
||||
const proProd = entity.products.find((p: any) => p.id === pro.id);
|
||||
expect(proProd).toBeDefined();
|
||||
expect(proProd.status).toBe(CusProductStatus.Scheduled);
|
||||
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: pro,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectScheduledApiSub({
|
||||
customerId,
|
||||
entityId: entity1.id,
|
||||
productId: pro.id,
|
||||
});
|
||||
// const entity = await autumn.entities.get(customerId, entity1.id);
|
||||
// const proProd = entity.products.find((p: any) => p.id === pro.id);
|
||||
// expect(proProd).toBeDefined();
|
||||
// expect(proProd.status).toBe(CusProductStatus.Scheduled);
|
||||
});
|
||||
return;
|
||||
|
||||
test("should advance test clock and have pro attached to entity 1", async () => {
|
||||
await advanceTestClock({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { LegacyVersion } from "@autumn/shared";
|
||||
import chalk from "chalk";
|
||||
import { addHours, addMonths } from "date-fns";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addHours, addMonths } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
type ApiCustomer,
|
||||
type ApiSubscription,
|
||||
ApiVersion,
|
||||
type CreateFreeTrial,
|
||||
CusProductStatus,
|
||||
type Entitlement,
|
||||
@@ -6,6 +9,7 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { Customer, ProductItem } from "autumn-js";
|
||||
import { expect } from "chai";
|
||||
import { AutumnInt } from "../../../src/external/autumn/autumnCli";
|
||||
|
||||
export const expectProductAttached = ({
|
||||
customer,
|
||||
@@ -63,6 +67,31 @@ export const expectProductAttached = ({
|
||||
}
|
||||
};
|
||||
|
||||
export const expectScheduledApiSub = async ({
|
||||
customerId,
|
||||
entityId,
|
||||
productId,
|
||||
}: {
|
||||
customerId: string;
|
||||
entityId?: string;
|
||||
productId: string;
|
||||
}) => {
|
||||
const autumnV2 = new AutumnInt({
|
||||
version: ApiVersion.V2_0,
|
||||
secretKey: process.env.UNIT_TEST_AUTUMN_SECRET_KEY,
|
||||
});
|
||||
|
||||
const entity = entityId
|
||||
? await autumnV2.entities.get(customerId, entityId)
|
||||
: await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
|
||||
const scheduledSub = entity.scheduled_subscriptions.find(
|
||||
(s: ApiSubscription) => s.plan_id === productId,
|
||||
);
|
||||
expect(scheduledSub, `scheduled subscription ${productId} is attached`).to
|
||||
.exist;
|
||||
};
|
||||
|
||||
export const expectProductV1Attached = ({
|
||||
customer,
|
||||
product,
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
type Organization,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js";
|
||||
import { expect } from "chai";
|
||||
import { addHours } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect.js";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { findStripePriceFromPrices } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
|
||||
@@ -22,7 +22,10 @@ import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils
|
||||
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
|
||||
import { hoursToFinalizeInvoice } from "../constants.js";
|
||||
import { advanceTestClock } from "../stripeUtils.js";
|
||||
import { expectProductAttached } from "./expectProductAttached.js";
|
||||
import {
|
||||
expectProductAttached,
|
||||
expectScheduledApiSub,
|
||||
} from "./expectProductAttached.js";
|
||||
import { expectSubItemsCorrect } from "./expectSubUtils.js";
|
||||
|
||||
export const expectNextCycleCorrect = async ({
|
||||
@@ -124,40 +127,19 @@ export const expectDowngradeCorrect = async ({
|
||||
isCanceled: true,
|
||||
});
|
||||
|
||||
// const { fullCus } = await expectSubItemsCorrect({
|
||||
// stripeCli,
|
||||
// customerId,
|
||||
// product: curProduct,
|
||||
// db,
|
||||
// org,
|
||||
// env,
|
||||
// subCanceled: isFreeProductV2({ product: newProduct }),
|
||||
// isCanceled: true,
|
||||
// });
|
||||
|
||||
const newProductIsFree = isFreeProductV2({ product: newProduct });
|
||||
|
||||
if (newProductIsFree) {
|
||||
// let res = await stripeCli.subscriptionSchedules.list({
|
||||
// customer: fullCus.processor?.id,
|
||||
// });
|
||||
// let data = res.data.filter((s) => s.status != "canceled");
|
||||
// expect(data.length, "should have no sub schedules").to.equal(0);
|
||||
// await expectSubScheduleCorrect({
|
||||
// stripeCli,
|
||||
// customerId,
|
||||
// productId: newProduct.id,
|
||||
// db,
|
||||
// org,
|
||||
// env,
|
||||
// });
|
||||
}
|
||||
expectProductAttached({
|
||||
customer,
|
||||
product: newProduct,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectScheduledApiSub({
|
||||
customerId,
|
||||
productId: newProduct.id,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
|
||||
@@ -31,7 +31,8 @@ export const ApiCustomerSchema = z.object({
|
||||
metadata: z.record(z.any(), z.any()),
|
||||
|
||||
subscriptions: z.array(ApiSubscriptionSchema),
|
||||
// scheduled_subscriptions: z.array(ApiSubscriptionSchema),
|
||||
|
||||
scheduled_subscriptions: z.array(ApiSubscriptionSchema),
|
||||
|
||||
balances: z.record(z.string(), ApiBalanceSchema),
|
||||
...ApiCusExpandSchema.shape,
|
||||
|
||||
@@ -54,14 +54,23 @@ export const V1_2_CustomerChange = defineVersionChange({
|
||||
// Response: V2.0 → V1.2
|
||||
transformResponse: ({ input, legacyData }) => {
|
||||
// Step 1: Transform plans V2.0 → V1.2 (products)
|
||||
const v3CusProducts: ApiCusProductV3[] = input.subscriptions.map(
|
||||
(subscription: ApiSubscription) =>
|
||||
transformSubscriptionToCusProductV3({
|
||||
input: subscription,
|
||||
legacyData: legacyData?.cusProductLegacyData[subscription.plan_id],
|
||||
}),
|
||||
const v3CusProducts: ApiCusProductV3[] = [
|
||||
...input.subscriptions,
|
||||
...input.scheduled_subscriptions,
|
||||
].map((subscription: ApiSubscription) =>
|
||||
transformSubscriptionToCusProductV3({
|
||||
input: subscription,
|
||||
legacyData: legacyData?.cusProductLegacyData[subscription.plan_id],
|
||||
}),
|
||||
);
|
||||
|
||||
v3CusProducts.sort((a, b) => {
|
||||
if (a.is_add_on === b.is_add_on) {
|
||||
return 0;
|
||||
}
|
||||
return a.is_add_on ? 1 : -1;
|
||||
});
|
||||
|
||||
// Step 2: Transform features V2.0 → V1.2
|
||||
const v3_features: Record<string, ApiCusFeatureV3> = {};
|
||||
for (const [featureId, feature] of Object.entries(input.balances)) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export const V1_2_CustomerQueryChange = defineVersionChange({
|
||||
const newExpand = [
|
||||
...existingExpand,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
CusExpand.BalancesFeature,
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const ApiEntityV1Schema = ApiBaseEntitySchema.extend({
|
||||
description: "Plans associated with this entity",
|
||||
example: [],
|
||||
}),
|
||||
scheduled_subscriptions: z.array(ApiSubscriptionSchema),
|
||||
balances: z.record(z.string(), ApiBalanceSchema).optional().meta({
|
||||
description: "Features associated with this entity",
|
||||
}),
|
||||
|
||||
@@ -62,6 +62,22 @@ export const V1_2_EntityChange = defineVersionChange({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const scheduledCusProducts: ApiCusProductV3[] | undefined =
|
||||
input.scheduled_subscriptions
|
||||
? input.scheduled_subscriptions.map((subscription: ApiSubscription) =>
|
||||
transformSubscriptionToCusProductV3({
|
||||
input: subscription,
|
||||
legacyData:
|
||||
legacyData?.cusProductLegacyData[subscription.plan_id],
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const finalCusProducts = [
|
||||
...(v0CusProducts || []),
|
||||
...(scheduledCusProducts || []),
|
||||
];
|
||||
|
||||
// Step 2: Transform features V1 → V0
|
||||
let v0_features: Record<string, ApiCusFeatureV3> | undefined;
|
||||
if (input.balances) {
|
||||
@@ -83,7 +99,7 @@ export const V1_2_EntityChange = defineVersionChange({
|
||||
feature_id: input.feature_id,
|
||||
created_at: input.created_at,
|
||||
env: input.env,
|
||||
products: v0CusProducts,
|
||||
products: finalCusProducts,
|
||||
features: v0_features,
|
||||
invoices:
|
||||
input.invoices?.map((invoice: ApiInvoiceV1) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ export const GetEntityQuerySchema = z.object({
|
||||
z.enum([
|
||||
CusExpand.Invoices,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
CusExpand.BalancesFeature,
|
||||
]),
|
||||
).default([]),
|
||||
|
||||
@@ -57,6 +57,7 @@ export const V1_2_EntityQueryChange = defineVersionChange({
|
||||
...existingExpand,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
] as GetEntityQuery["expand"];
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,5 +21,6 @@ export enum CusExpand {
|
||||
// PlansPlan = "plans.plan",
|
||||
|
||||
SubscriptionsPlan = "subscriptions.plan",
|
||||
ScheduledSubscriptionsPlan = "scheduled_subscriptions.plan",
|
||||
BalancesFeature = "balances.feature",
|
||||
}
|
||||
|
||||
@@ -69,5 +69,16 @@ export const filterPlanAndFeatureExpand = <
|
||||
}
|
||||
}
|
||||
|
||||
const expandScheduledSubscriptionPlan = expandIncludes({
|
||||
expand,
|
||||
includes: [CusExpand.ScheduledSubscriptionsPlan],
|
||||
});
|
||||
|
||||
if (!expandScheduledSubscriptionPlan && target.scheduled_subscriptions) {
|
||||
for (let i = 0; i < target.scheduled_subscriptions?.length; i++) {
|
||||
target.scheduled_subscriptions[i].plan = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return target as T;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
sentryVitePlugin({
|
||||
org: process.env.VITE_SENTRY_ORG,
|
||||
project: process.env.VITE_SENTRY_PROJECT,
|
||||
telemetry: false
|
||||
}),
|
||||
],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user