Merge branch 'staging' of https://github.com/useautumn/autumn into staging

pull remote staging
This commit is contained in:
John Yeo
2025-11-26 10:46:50 +00:00
40 changed files with 475 additions and 171 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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,

View File

@@ -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),
);

View File

@@ -163,7 +163,7 @@ export const handleUpsertInstallation = createRoute({
}),
orgCurrency: ctx.org.default_currency ?? "usd",
})
: null,
: undefined,
};
return c.json(installation, 200);

View File

@@ -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,
);
}
},
});

View File

@@ -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 };
};

View File

@@ -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,
],
});
}

View File

@@ -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");
};

View File

@@ -37,6 +37,7 @@ export const setCachedApiCustomer = async ({
CusExpand.BalancesFeature,
CusExpand.SubscriptionsPlan,
CusExpand.Invoices,
CusExpand.ScheduledSubscriptionsPlan,
],
});

View File

@@ -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)`,
);
}
});

View File

@@ -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:

View File

@@ -29,6 +29,7 @@ export const getApiCustomerExpand = async ({
filter: [
CusExpand.BalancesFeature,
CusExpand.SubscriptionsPlan,
CusExpand.ScheduledSubscriptionsPlan,
CusExpand.Invoices,
],
});

View File

@@ -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({

View File

@@ -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,
),

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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`);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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";

View File

@@ -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({

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -53,6 +53,7 @@ export const V1_2_CustomerQueryChange = defineVersionChange({
const newExpand = [
...existingExpand,
CusExpand.SubscriptionsPlan,
CusExpand.ScheduledSubscriptionsPlan,
CusExpand.BalancesFeature,
];

View File

@@ -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",
}),

View File

@@ -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) =>

View File

@@ -23,6 +23,7 @@ export const GetEntityQuerySchema = z.object({
z.enum([
CusExpand.Invoices,
CusExpand.SubscriptionsPlan,
CusExpand.ScheduledSubscriptionsPlan,
CusExpand.BalancesFeature,
]),
).default([]),

View File

@@ -57,6 +57,7 @@ export const V1_2_EntityQueryChange = defineVersionChange({
...existingExpand,
CusExpand.SubscriptionsPlan,
CusExpand.BalancesFeature,
CusExpand.ScheduledSubscriptionsPlan,
] as GetEntityQuery["expand"];
return {

View File

@@ -21,5 +21,6 @@ export enum CusExpand {
// PlansPlan = "plans.plan",
SubscriptionsPlan = "subscriptions.plan",
ScheduledSubscriptionsPlan = "scheduled_subscriptions.plan",
BalancesFeature = "balances.feature",
}

View File

@@ -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;
};

View File

@@ -14,6 +14,7 @@ export default defineConfig({
sentryVitePlugin({
org: process.env.VITE_SENTRY_ORG,
project: process.env.VITE_SENTRY_PROJECT,
telemetry: false
}),
],