Merge branch 'dev' into feat/external-psp-signals-on-customers

This commit is contained in:
amianthus
2026-05-07 12:36:16 +01:00
155 changed files with 5011 additions and 1611 deletions

View File

@@ -1,26 +0,0 @@
## Summary
<!-- Provide a short summary of your changes and the motivation behind them. -->
## Related Issues
<!-- List any related issues, e.g. Fixes #123 or Closes #456 -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Refactor
- [ ] Other (please describe):
## Checklist
- [ ] I have read the [CONTRIBUTING.md](https://github.com/useautumn/autumn/blob/staging/.github/CONTRIBUTING.md)
- [ ] My code follows the code style of this project
- [ ] I have added tests where applicable
- [ ] I have tested my changes locally
- [ ] I have linked relevant issues
- [ ] I have added screenshots for UI changes (if applicable)
## Screenshots (if applicable)
<!-- Add before/after screenshots or GIFs here -->
## Additional Context
<!-- Add any other context or information about the PR here -->

View File

@@ -48,7 +48,7 @@
"cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=prod --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\""
],
"env": {
"AUTUMN_REPO_ROOT": "/Users/johnyeocx/Autumn"
"AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud"
}
}
},

2
ai

Submodule ai updated: e21275f18e...18d68d3c2a

View File

@@ -4,6 +4,7 @@ import {
ms,
orgToFeaturesByOrgEnv,
} from "@autumn/shared";
import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js";
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects";
import { customerProductRepo } from "@/internal/customers/cusProducts/repos";
import { ProductService } from "@/internal/products/ProductService";
@@ -76,7 +77,10 @@ export const runProductCron = async ({
await batchInvalidateCachedFullSubjects({
customers: customersToDelete,
featuresByOrgEnv,
redisV2: ctx.redisV2,
getRedisTargetsForCustomer: () =>
getRedisTargetsForCustomer({
org,
}),
});
console.log(`Expired ${rows.length} customer products`);
continue;

View File

@@ -8,7 +8,8 @@ import {
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import type { RepoContext } from "@/db/repoContext";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { resolveCustomerRedisRouting } from "@/external/redis/customerRedisRouting.js";
import type { OrgWithRedisConfig } from "@/external/redis/orgRedisPool.js";
import { invalidateCustomerEntitlementBalance } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { getRelatedCusPrice } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
@@ -26,15 +27,21 @@ const shortDurations = [EntInterval.Minute, EntInterval.Hour, EntInterval.Day];
const resetCustomerEntitlementInDb = async ({
ctx,
org,
cusEnt,
updatedCusEnts,
persistFreeOverage = false,
}: {
ctx: CronContext;
org: OrgWithRedisConfig;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
persistFreeOverage?: boolean;
}) => {
const redisRouting = resolveCustomerRedisRouting({
org,
customerId: cusEnt.customer_id ?? "",
});
const repoContext: RepoContext = {
db: ctx.db,
logger: ctx.logger,
@@ -43,7 +50,7 @@ const resetCustomerEntitlementInDb = async ({
},
env: cusEnt.customer.env,
customerId: cusEnt.customer_id ?? "",
redisV2: resolveRedisV2(),
redisV2: redisRouting.redis,
};
try {
@@ -186,17 +193,25 @@ const resetCustomerEntitlementInDb = async ({
export const resetCustomerEntitlement = async ({
ctx,
org,
cusEnt,
updatedCusEnts,
persistFreeOverage = false,
}: {
ctx: CronContext;
org?: OrgWithRedisConfig;
cusEnt: ResetCusEnt;
updatedCusEnts: ResetCusEnt[];
persistFreeOverage?: boolean;
}) => {
const routingOrg = org ?? { id: cusEnt.customer.org_id, redis_config: null };
const redisRouting = resolveCustomerRedisRouting({
org: routingOrg,
customerId: cusEnt.customer_id ?? "",
});
const result = await resetCustomerEntitlementInDb({
ctx,
org: routingOrg,
cusEnt,
updatedCusEnts,
persistFreeOverage,
@@ -207,7 +222,7 @@ export const resetCustomerEntitlement = async ({
customerId: cusEnt.customer_id ?? "",
featureId: cusEnt.entitlement.feature.id,
customerEntitlementId: cusEnt.id,
redisV2: resolveRedisV2(),
redisV2: redisRouting.redis,
});
return result;
};

View File

@@ -121,6 +121,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
batchResets.push(
resetCustomerEntitlement({
ctx,
org: orgWithFeatures.org,
cusEnt: cusEnt,
updatedCusEnts,
persistFreeOverage:

View File

@@ -0,0 +1,110 @@
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrgRedis, type OrgWithRedisConfig } from "./orgRedisPool.js";
import { resolveRedisV2 } from "./resolveRedisV2.js";
export {
type CustomerRedisRoutingInfo,
getCustomerBucket,
getCustomerRedisRoutingId,
isRedisMigrationCacheStale,
} from "./customerRedisRoutingInfo.js";
import {
type CustomerRedisRoutingInfo,
getCustomerRedisRoutingInfoForOrg,
} from "./customerRedisRoutingInfo.js";
export const getCustomerRedisRoutingInfo = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo => {
return getCustomerRedisRoutingInfoForOrg({
org,
customerId,
});
};
export const resolveCustomerRedisRouting = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo & { redis: Redis } => {
const routingInfo = getCustomerRedisRoutingInfo({ org, customerId });
if (routingInfo.usesDedicatedRedis) {
return {
...routingInfo,
redis: getOrgRedis({ org }),
};
}
return {
...routingInfo,
redis: resolveRedisV2(),
};
};
export const getCtxWithCustomerRedis = <T extends AutumnContext>({
ctx,
customerId = ctx.customerId,
}: {
ctx: T;
customerId?: string;
}): { ctx: T; routingInfo: CustomerRedisRoutingInfo } => {
const routingInfo = resolveCustomerRedisRouting({
org: ctx.org,
customerId,
});
return {
ctx: {
...ctx,
redisV2: routingInfo.redis,
} as T,
routingInfo,
};
};
export const overrideCtxRedisV2 = ({
ctx,
redisV2,
}: {
ctx: AutumnContext;
redisV2: Redis;
}): AutumnContext => {
if (ctx.redisV2 === redisV2) return ctx;
const injectedCtx = Object.create(ctx) as AutumnContext;
injectedCtx.redisV2 = redisV2;
return injectedCtx;
};
export const getRedisUrlForCustomer = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): string | undefined => {
return getCustomerRedisRoutingInfo({ org, customerId }).redisUrl;
};
export const getRedisTargetsForCustomer = ({
org,
currentRedis,
}: {
org: OrgWithRedisConfig;
currentRedis?: Redis;
}): Redis[] => {
const redisTargets = [currentRedis ?? resolveRedisV2()];
if (org.redis_config) {
redisTargets.push(resolveRedisV2(), getOrgRedis({ org }));
}
return [...new Set(redisTargets)];
};

View File

@@ -0,0 +1,62 @@
import type { OrgRedisConfig } from "@autumn/shared";
import type { OrgWithRedisConfig } from "./orgRedisPool.js";
export type CustomerRedisRoutingInfo = {
bucket?: number;
redisUrl?: string;
usesDedicatedRedis: boolean;
};
// Mirrors full-subject cache identity: public ID first, internal ID fallback.
export const getCustomerRedisRoutingId = ({
customer,
}: {
customer: { id?: string | null; internal_id: string };
}): string => customer.id ?? customer.internal_id;
// Deterministic rollout bucket, not a cryptographic hash.
export const getCustomerBucket = (customerId: string): number =>
Number(BigInt(Bun.hash(customerId)) % 100n);
export const getCustomerRedisRoutingInfoForOrg = ({
org,
customerId,
}: {
org: OrgWithRedisConfig;
customerId?: string;
}): CustomerRedisRoutingInfo => {
if (!org.redis_config || !customerId) {
return {
usesDedicatedRedis: false,
};
}
const bucket = getCustomerBucket(customerId);
const usesDedicatedRedis = bucket < org.redis_config.migrationPercent;
return {
bucket,
redisUrl: usesDedicatedRedis ? org.redis_config.url : undefined,
usesDedicatedRedis,
};
};
export const isRedisMigrationCacheStale = ({
cachedAt,
customerId,
redisConfig,
}: {
cachedAt?: number;
customerId?: string;
redisConfig?: OrgRedisConfig | null;
}): boolean => {
if (!redisConfig?.migrationChangedAt) return false;
if (!customerId) return false;
if (cachedAt === undefined) return false;
if (cachedAt >= redisConfig.migrationChangedAt) return false;
const bucket = getCustomerBucket(customerId);
const wasOnDedicated = bucket < redisConfig.previousMigrationPercent;
const isOnDedicated = bucket < redisConfig.migrationPercent;
return wasOnDedicated !== isOnDedicated;
};

View File

@@ -0,0 +1,102 @@
import type { OrgRedisConfig } from "@autumn/shared";
import type { Redis } from "ioredis";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { decryptData } from "@/utils/encryptUtils.js";
import { createRedisConnection, currentRegion } from "./initRedis.js";
import { REDIS_V2_COMMAND_TIMEOUT_MS } from "./initUtils/redisV2Config.js";
import { resolveRedisV2 } from "./resolveRedisV2.js";
export type OrgWithRedisConfig = {
id: string;
redis_config?: OrgRedisConfig | null;
};
type PoolEntry = {
instance: Redis;
url: string;
};
const pool = new Map<string, PoolEntry>();
const createOrgRedisConnection = ({
connectionString,
orgId,
}: {
connectionString: string;
orgId: string;
}): Redis => {
const instance = createRedisConnection({
cacheUrl: connectionString,
region: `org:${orgId}:v2:dragonfly`,
supportsUpstashShebang: false,
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
});
instance.on("error", (error) => {
logger.error(`[OrgRedis] org=${orgId}: ${error.message}`);
});
instance.on("ready", () => {
logger.info(`[OrgRedis] org=${orgId}: connected`);
});
return instance;
};
export const getOrgRedis = ({ org }: { org: OrgWithRedisConfig }): Redis => {
if (!org.redis_config) return resolveRedisV2();
const existing = pool.get(org.id);
if (existing) {
if (existing.url === org.redis_config.url) return existing.instance;
existing.instance.disconnect();
pool.delete(org.id);
}
let connectionString: string;
try {
connectionString = decryptData(org.redis_config.connectionString);
} catch (error) {
logger.error(
`[OrgRedis] Failed to decrypt redis_config for org ${org.id}, falling back to shared Redis V2`,
);
if (error instanceof Error) {
logger.error(error);
}
return resolveRedisV2();
}
const instance = createOrgRedisConnection({
connectionString,
orgId: org.id,
});
pool.set(org.id, { instance, url: org.redis_config.url });
return instance;
};
export const removeOrgRedis = ({ orgId }: { orgId: string }): void => {
const existing = pool.get(orgId);
if (!existing) return;
existing.instance.disconnect();
pool.delete(orgId);
};
export const preWarmOrgRedisConnections = async ({
db,
}: {
db: DrizzleCli;
}): Promise<void> => {
const orgsWithRedis = await OrgService.listWithRedisConfig({ db });
if (orgsWithRedis.length === 0) return;
logger.info(
`[OrgRedis] Pre-warming connections for ${orgsWithRedis.length} orgs in ${currentRegion}...`,
);
for (const org of orgsWithRedis) {
getOrgRedis({ org });
}
};

View File

@@ -0,0 +1,37 @@
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrgRedis } from "../orgRedisPool.js";
import { resolveRedisV2 } from "../resolveRedisV2.js";
const dedupeRedisInstances = ({ candidates }: { candidates: Redis[] }) =>
candidates.filter(
(candidate, index) => candidates.indexOf(candidate) === index,
);
export const getRedisV2LockReceiptCandidates = ({
ctx,
}: {
ctx: AutumnContext;
}): Redis[] => {
const candidates: Redis[] = [ctx.redisV2];
if (ctx.org.redis_config && ctx.org.redis_config.migrationPercent > 0) {
candidates.push(getOrgRedis({ org: ctx.org }), resolveRedisV2());
}
return dedupeRedisInstances({ candidates });
};
export const getRedisV2OrgCleanupCandidates = ({
ctx,
}: {
ctx: AutumnContext;
}): Redis[] => {
const candidates: Redis[] = [ctx.redisV2, resolveRedisV2()];
if (ctx.org.redis_config) {
candidates.push(getOrgRedis({ org: ctx.org }));
}
return dedupeRedisInstances({ candidates });
};

View File

@@ -6,12 +6,13 @@ import {
ProcessorType,
RecaseError,
} from "@shared/index";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import { RCMappingService } from "@/external/revenueCat/misc/RCMappingService";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { ProductService } from "@/internal/products/ProductService";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { getOrCreateCustomer } from "../../../internal/customers/cusUtils/getOrCreateCustomer";
/**
@@ -30,6 +31,7 @@ export const resolveRevenuecatResources = async ({
customerId: string;
autoCreateCustomer?: boolean;
}): Promise<{
ctx: RevenueCatWebhookContext;
product: FullProduct;
customer: FullCustomer;
cusProducts: FullCusProduct[];
@@ -102,6 +104,10 @@ export const resolveRevenuecatResources = async ({
orgId: ctx.org.id,
customerId: ctx.customerId,
});
const { ctx: routedCtx } = getCtxWithCustomerRedis({
ctx,
customerId: ctx.customerId,
});
return { product, customer, cusProducts };
return { ctx: routedCtx, product, customer, cusProducts };
};

View File

@@ -29,7 +29,12 @@ export const handleRenewal = async ({
const { db, org, env, logger, features } = ctx;
const { product_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id,
@@ -50,7 +55,7 @@ export const handleRenewal = async ({
// Send webhook for simple renewal (no state change)
await addProductsUpdatedWebhookTask({
ctx,
ctx: customerCtx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,
@@ -68,7 +73,7 @@ export const handleRenewal = async ({
`Renewal for existing past due product ${product.id}, marking as active`,
);
await CusProductService.update({
ctx,
ctx: customerCtx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Active,
@@ -77,7 +82,7 @@ export const handleRenewal = async ({
// Send webhook for past_due → active recovery
await addProductsUpdatedWebhookTask({
ctx,
ctx: customerCtx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,
@@ -111,7 +116,7 @@ export const handleRenewal = async ({
// Expire old cus_product
await CusProductService.update({
ctx,
ctx: customerCtx,
cusProductId: curMainProduct.id,
updates: {
status: CusProductStatus.Expired,
@@ -121,7 +126,7 @@ export const handleRenewal = async ({
// Send webhook for the expired product
await addProductsUpdatedWebhookTask({
ctx,
ctx: customerCtx,
internalCustomerId: curMainProduct.internal_customer_id,
org,
env,
@@ -134,7 +139,7 @@ export const handleRenewal = async ({
} else if (curSameProduct) {
// Reactivate the same product if it was expired/cancelled
await CusProductService.update({
ctx,
ctx: customerCtx,
cusProductId: curSameProduct.id,
updates: {
status: CusProductStatus.Active,
@@ -146,7 +151,7 @@ export const handleRenewal = async ({
// Send webhook for reactivation
await addProductsUpdatedWebhookTask({
ctx,
ctx: customerCtx,
internalCustomerId: curSameProduct.internal_customer_id,
org,
env,

View File

@@ -3,10 +3,8 @@ import { RecaseError } from "@shared/api/errors/base/RecaseError";
import { ErrCode } from "@shared/enums/ErrCode";
import { CusProductStatus } from "@shared/models/cusProductModels/cusProductEnums";
import type { RevenueCatWebhookContext } from "@/external/revenueCat/webhookMiddlewares/revenuecatWebhookContext";
import {
ACTIVE_STATUSES,
} from "@/internal/customers/cusProducts/CusProductService";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts";
import { resolveRevenuecatResources } from "../misc/resolveRevenuecatResources";
@@ -20,7 +18,12 @@ export const handleBillingIssue = async ({
const { logger } = ctx;
const { product_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id,
@@ -48,7 +51,7 @@ export const handleBillingIssue = async ({
if (ACTIVE_STATUSES.includes(curSameProduct.status)) {
await customerProductActions.markPastDue({
ctx,
ctx: customerCtx,
customerProduct: curSameProduct,
fullCustomer: customer,
});

View File

@@ -16,7 +16,12 @@ export const handleCancellation = async ({
const { product_id, original_app_user_id, app_user_id, expiration_at_ms } =
event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id ?? original_app_user_id,
@@ -36,7 +41,7 @@ export const handleCancellation = async ({
}
await customerProductActions.cancel({
ctx,
ctx: customerCtx,
customerProduct: curSameProduct,
fullCustomer: customer,
endedAt: expiration_at_ms,

View File

@@ -15,7 +15,12 @@ export const handleExpiration = async ({
const { logger } = ctx;
const { product_id, original_app_user_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id ?? original_app_user_id,
@@ -35,7 +40,7 @@ export const handleExpiration = async ({
}
await customerProductActions.expireAndActivateDefault({
ctx,
ctx: customerCtx,
customerProduct: curSameProduct,
fullCustomer: customer,
updates: {

View File

@@ -29,7 +29,12 @@ export const handleInitialPurchase = async ({
const { db, org, env, logger, features } = ctx;
const { product_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id,
@@ -73,7 +78,7 @@ export const handleInitialPurchase = async ({
// Expire old cus_product
await CusProductService.update({
ctx,
ctx: customerCtx,
cusProductId: curMainProduct.id,
updates: {
status: CusProductStatus.Expired,

View File

@@ -14,7 +14,12 @@ export const handleUncancellation = async ({
const { logger } = ctx;
const { product_id, original_app_user_id, app_user_id } = event;
const { product, customer, cusProducts } = await resolveRevenuecatResources({
const {
ctx: customerCtx,
product,
customer,
cusProducts,
} = await resolveRevenuecatResources({
ctx,
revenuecatProductId: product_id,
customerId: app_user_id ?? original_app_user_id,
@@ -35,7 +40,7 @@ export const handleUncancellation = async ({
}
await customerProductActions.uncancel({
ctx,
ctx: customerCtx,
customerProduct: cusProduct,
fullCustomer: customer,
});

View File

@@ -10,7 +10,10 @@ import { StatusCodes } from "http-status-codes";
import type { Stripe } from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import {
getCustomerRedisRoutingId,
resolveCustomerRedisRouting,
} from "@/external/redis/customerRedisRouting.js";
import { createStripeCustomer } from "@/external/stripe/customers";
import { CusService } from "@/internal/customers/CusService.js";
@@ -150,7 +153,10 @@ export const attachPmToCus = async ({
org,
env,
logger: logger,
redisV2: resolveRedisV2(),
redisV2: resolveCustomerRedisRouting({
org,
customerId: getCustomerRedisRoutingId({ customer }),
}).redis,
};
await CusService.update({

View File

@@ -34,6 +34,9 @@ export const handleSchedulePhaseChanges = async ({
return;
}
// Step 1: Activate scheduled products; checkout trial-end updates have no schedule phase change.
await activateScheduledCustomerProducts({ ctx, eventContext });
// Check if phase possibly changed (items changed and schedule exists)
const phasePossiblyChanged =
notNullish(previousAttributes?.items) &&
@@ -52,10 +55,7 @@ export const handleSchedulePhaseChanges = async ({
`[handleSchedulePhaseChanges] sub: ${stripeSubscription.id}, now: ${formatMs(nowMs)}, currentPhase: ${currentPhaseIndex + 1}/${stripeSubscriptionSchedule.phases.length}`,
);
// Step 1: Activate scheduled customer products
await activateScheduledCustomerProducts({ ctx, eventContext });
// Step 2: Expire ended customer products (uses updated customerProducts from step 1)
// Step 2: Expire ended customer products (uses updated customerProducts)
await expireEndedCustomerProducts({ ctx, eventContext });
// Step 3: Release schedule if at last phase

View File

@@ -1,5 +1,6 @@
import { RELEVANT_STATUSES } from "@autumn/shared";
import type { Context, Next } from "hono";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
import { CusService } from "../../../internal/customers/CusService";
import type {
@@ -63,11 +64,18 @@ export const stripeToAutumnCustomerMiddleware = async (
ctx.fullCustomer?.id || ctx.fullCustomer?.internal_id || undefined;
if (customerId) {
ctx.customerId = customerId;
ctx.rolloutSnapshot = computeRolloutSnapshot({
orgId: ctx.org.id,
const { ctx: routedCtx } = getCtxWithCustomerRedis({
ctx: {
...ctx,
customerId,
rolloutSnapshot: computeRolloutSnapshot({
orgId: ctx.org.id,
customerId,
}),
},
customerId,
});
c.set("ctx", routedCtx);
}
await next();

View File

@@ -1,4 +1,5 @@
import type { Context, Next } from "hono";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { CusService } from "@/internal/customers/CusService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -34,15 +35,26 @@ export const vercelCustomerMiddleware = async (
vercelInstallationId: integrationConfigurationId,
});
ctx.fullCustomer = customer ?? undefined;
const customerId = customer?.id || customer?.internal_id || undefined;
if (customerId) {
ctx.customerId = customerId;
ctx.rolloutSnapshot = computeRolloutSnapshot({
orgId: ctx.org.id,
const { ctx: routedCtx } = getCtxWithCustomerRedis({
ctx: {
...ctx,
fullCustomer: customer ?? undefined,
customerId,
rolloutSnapshot: computeRolloutSnapshot({
orgId: ctx.org.id,
customerId,
}),
},
customerId,
});
c.set("ctx", routedCtx);
} else {
c.set("ctx", {
...ctx,
fullCustomer: customer ?? undefined,
});
}
await next();

View File

@@ -2,6 +2,7 @@ import { AppEnv, AuthType, type Organization } from "@autumn/shared";
import chalk from "chalk";
import type { Context, Next } from "hono";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -12,41 +13,50 @@ export const vercelSeederMiddleware = async (
c: Context<HonoEnv>,
next: Next,
) => {
const { orgId, env } = c.req.param();
const { orgId, env: routeEnv } = c.req.param();
const ctx = c.get("ctx");
if (!ctx.org && orgId) {
ctx.org = await OrgService.get({ db: ctx.db, orgId });
}
const org =
!ctx.org && orgId ? await OrgService.get({ db: ctx.db, orgId }) : ctx.org;
const env = ctx.env !== routeEnv ? (routeEnv as AppEnv) : ctx.env;
if (ctx.env !== env) {
ctx.env = env as AppEnv;
}
const features =
!ctx.features && orgId
? await FeatureService.list({
db: ctx.db,
orgId,
env: env ?? AppEnv.Sandbox,
})
: ctx.features;
if (!ctx.features && orgId) {
ctx.features = await FeatureService.list({
db: ctx.db,
orgId,
env: ctx.env ?? AppEnv.Sandbox,
});
}
const nextCtx = {
...ctx,
org,
env,
features,
rolloutSnapshot: computeRolloutSnapshot({
orgId: org?.id,
customerId: ctx.customerId,
}),
};
ctx.rolloutSnapshot = computeRolloutSnapshot({
orgId: ctx.org?.id,
customerId: ctx.customerId,
});
const routedCtx = org
? getCtxWithCustomerRedis({ ctx: nextCtx }).ctx
: nextCtx;
ctx.logger = addAppContextToLogs({
logger: ctx.logger,
routedCtx.logger = addAppContextToLogs({
logger: routedCtx.logger,
appContext: {
org_id: ctx.org?.id,
org_slug: ctx.org?.slug,
env: ctx.env,
org_id: routedCtx.org?.id,
org_slug: routedCtx.org?.slug,
env: routedCtx.env,
auth_type: AuthType.Vercel,
api_version: ctx.apiVersion?.semver,
api_version: routedCtx.apiVersion?.semver,
},
});
c.set("ctx", routedCtx);
await next();
};
@@ -57,7 +67,7 @@ export const logVercelWebhook = ({
}: {
logger: Logger;
org: Organization;
event: any;
event: { type: string; id: string };
}) => {
logger.info(
`${chalk.magenta("VERCEL").padEnd(18)} ${event.type.padEnd(30)} ${org.slug} | ${event.id}`,

View File

@@ -16,6 +16,26 @@ import { addRequestToLogs } from "@/utils/logging/addContextToLogs";
import { resolveCustomerId } from "./utils/resolveCustomerId.js";
import { resolveEntityId } from "./utils/resolveEntityId.js";
const SENSITIVE_REQUEST_BODY_KEYS = new Set(["connectionString"]);
const REDACTED_REQUEST_BODY_VALUE = "[REDACTED]";
const redactSensitiveRequestBody = ({ body }: { body: unknown }): unknown => {
if (!body || typeof body !== "object") return body;
if (Array.isArray(body)) {
return body.map((item) => redactSensitiveRequestBody({ body: item }));
}
return Object.fromEntries(
Object.entries(body).map(([key, value]) => [
key,
SENSITIVE_REQUEST_BODY_KEYS.has(key)
? REDACTED_REQUEST_BODY_VALUE
: redactSensitiveRequestBody({ body: value }),
]),
);
};
/**
* Base middleware that sets up the request context
* Sets up: db, logger, id, timestamp
@@ -61,7 +81,7 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
ip_address: c.req.header("x-forwarded-for"),
region: process.env.AWS_REGION,
query: c.req.query(),
body,
body: redactSensitiveRequestBody({ body }),
name: `${c.req.method} ${c.req.path}`,
},

View File

@@ -0,0 +1,13 @@
import type { Context, Next } from "hono";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
export const orgRedisMiddleware = async (
c: Context<HonoEnv>,
next: Next,
): Promise<void> => {
const ctx = c.get("ctx");
const { ctx: routedCtx } = getCtxWithCustomerRedis({ ctx });
c.set("ctx", routedCtx);
await next();
};

View File

@@ -5,7 +5,7 @@ import cluster from "node:cluster";
import http from "node:http";
import os from "node:os";
import { getRequestListener } from "@hono/node-server";
import { client, clientCritical, clientReplica } from "./db/initDrizzle.js";
import { client, clientCritical, clientReplica, db } from "./db/initDrizzle.js";
import {
initPgHealthMonitor,
shutdownPgHealthMonitor,
@@ -38,6 +38,7 @@ import {
startRedisV2Monitor,
stopRedisV2Monitor,
} from "./external/redis/initUtils/redisV2Availability.js";
import { preWarmOrgRedisConnections } from "./external/redis/orgRedisPool.js";
import { createHonoApp } from "./initHono.js";
import { otelSdk } from "./instrumentation.js";
import { checkEnvVars } from "./utils/initUtils.js";
@@ -59,6 +60,9 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => {
void warmupRegionalRedis().catch((error) => {
logger.warn("[Redis] Warmup failed", { error });
});
void preWarmOrgRedisConnections({ db }).catch((error) => {
logger.warn("[OrgRedis] Warmup failed", { error });
});
await startAllEdgeConfigPolling({ logger });
await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]);
startRedisMonitor();

View File

@@ -17,6 +17,7 @@ import { replicaDbMiddleware } from "./honoMiddlewares/replicaDbMiddleware.js";
import type { HonoEnv } from "./honoUtils/HonoEnv.js";
import { handleHealthCheck } from "./honoUtils/handleHealthCheck.js";
import { handleReadyCheck } from "./honoUtils/handleReadyCheck.js";
import { handleListAuthOrganizations } from "./internal/auth/handleListAuthOrganizations.js";
import { cliRouter } from "./internal/dev/cli/cliRouter.js";
import { handleOAuthCallback } from "./internal/orgs/handlers/stripeHandlers/handleOAuthCallback.js";
import { apiRouter } from "./routers/apiRouter.js";
@@ -73,6 +74,9 @@ export const createHonoApp = () => {
return oauthProviderAuthServerMetadata(auth)(c.req.raw);
});
// Better Auth's joined Drizzle query defaults to 100 memberships.
app.get("/api/auth/organization/list", handleListAuthOrganizations);
app.on(["POST", "GET"], "/api/auth/*", (c) => {
return auth.handler(c.req.raw);
});

View File

@@ -0,0 +1,42 @@
import { ErrCode, member, organizations, RecaseError } from "@autumn/shared";
import { asc, eq } from "drizzle-orm";
import type { Context } from "hono";
import { db } from "@/db/initDrizzle.js";
import { auth } from "@/utils/auth.js";
const AUTH_ORGANIZATION_LIST_LIMIT = 1000;
export const handleListAuthOrganizations = async (c: Context) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session?.user?.id) {
throw new RecaseError({
message: "Unauthorized - no session found",
code: ErrCode.NoAuthHeader,
statusCode: 401,
});
}
const orgs = await db
.select({
id: organizations.id,
name: organizations.name,
slug: organizations.slug,
logo: organizations.logo,
createdAt: organizations.createdAt,
metadata: organizations.metadata,
})
.from(member)
.innerJoin(organizations, eq(member.organizationId, organizations.id))
.where(eq(member.userId, session.user.id))
.orderBy(
asc(organizations.name),
asc(organizations.slug),
asc(organizations.id),
)
.limit(AUTH_ORGANIZATION_LIST_LIMIT);
return c.json(orgs);
};

View File

@@ -1,4 +1,5 @@
import {
type ApiBalanceV1,
ApiVersion,
CheckResponseV3Schema,
ErrCode,
@@ -77,6 +78,7 @@ export const runCheckWithTrackV2 = async ({
};
let allowed = true;
let trackBalances: Record<string, ApiBalanceV1 | null> | undefined;
try {
const response = await runTrackV3({
@@ -86,8 +88,11 @@ export const runCheckWithTrackV2 = async ({
apiVersion: ApiVersion.V2_1,
});
checkData.apiBalance = response.balance ?? undefined;
checkData.evaluationApiBalance = response.balance ?? undefined;
const trackedBalance =
response.balance ?? response.balances?.[body.feature_id] ?? undefined;
checkData.apiBalance = trackedBalance ?? undefined;
checkData.evaluationApiBalance = trackedBalance ?? undefined;
trackBalances = response.balances;
} catch (error) {
if (error instanceof InsufficientBalanceError) {
allowed = false;
@@ -140,6 +145,7 @@ export const runCheckWithTrackV2 = async ({
entity_id: checkData.entityId,
required_balance: requiredBalance,
balance: checkData.apiBalance ?? null,
balances: trackBalances,
flag: checkData.apiFlag ?? null,
});
};

View File

@@ -48,6 +48,7 @@ const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => {
receipt: fetchedReceipt.receipt,
lockReceiptKey: fetchedReceipt.lockReceiptKey,
claimed: fetchedReceipt.claimed,
lockRedisInstance: fetchedReceipt.redisInstance,
});
}

View File

@@ -5,6 +5,7 @@ import {
RecaseError,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { cancelLockExpiry } from "@/internal/balances/utils/lock/cancelLockExpiry.js";
import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
@@ -24,12 +25,14 @@ export const runFinalizeLockV2 = async ({
receipt,
lockReceiptKey,
claimed,
lockRedisInstance,
}: {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
receipt: LockReceipt;
lockReceiptKey: string;
claimed: boolean;
lockRedisInstance: Redis;
}) => {
if (!claimed) {
throw new RecaseError({
@@ -45,6 +48,7 @@ export const runFinalizeLockV2 = async ({
params,
receipt,
lockReceiptKey,
redisInstance: lockRedisInstance,
});
const { redisInstance, finalValue, lockValue } = finalizeLockContext;

View File

@@ -15,7 +15,7 @@ export const runRedisFinalizeLockV2 = async ({
ctx: AutumnContext;
finalizeLockContext: FinalizeLockContextV2;
}) => {
const { receipt, fullSubject, deduction, deductionOptions } =
const { receipt, fullSubject, deduction, deductionOptions, redisInstance } =
finalizeLockContext;
let redisResult: Awaited<ReturnType<typeof executeRedisDeductionV2>>;
@@ -27,6 +27,7 @@ export const runRedisFinalizeLockV2 = async ({
entityId: receipt.entity_id ?? undefined,
deductions: [deduction],
deductionOptions,
redisInstance,
});
} catch (error) {
if (error instanceof RedisDeductionError && error.shouldFallback()) {

View File

@@ -34,10 +34,7 @@ export const calculateDeduction = ({
const maxAddable =
maxBalance === undefined
? amountToAdd
: Math.max(
0,
new Decimal(maxBalance).sub(currentBalance).toNumber(),
);
: Math.max(0, new Decimal(maxBalance).sub(currentBalance).toNumber());
const added = Math.min(amountToAdd, maxAddable);
deducted = -added;
@@ -46,10 +43,7 @@ export const calculateDeduction = ({
const maxDeductible =
minBalance === undefined
? amountToDeduct
: Math.max(
0,
new Decimal(currentBalance).sub(minBalance).toNumber(),
);
: Math.max(0, new Decimal(currentBalance).sub(minBalance).toNumber());
deducted = Math.min(amountToDeduct, maxDeductible);
newBalance = new Decimal(currentBalance).sub(deducted).toNumber();
}

View File

@@ -1,4 +1,9 @@
import { ErrCode, RecaseError, type ApiVersion, type TrackParams } from "@autumn/shared";
import {
type ApiVersion,
ErrCode,
RecaseError,
type TrackParams,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getTrackFeatureDeductionsForBody } from "./utils/getFeatureDeductions.js";
import { runTrackV3 } from "./v3/runTrackV3.js";

View File

@@ -0,0 +1,42 @@
import {
type ApiBalanceV1,
type ApiCustomerV5,
getRelevantFeatures,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
/**
* Builds the `balances` map for a track/check response: the tracked feature's
* balance plus every linked credit system's balance (via getRelevantFeatures).
* Entries are null when the customer has no balance for that feature.
*
* Returns undefined when fewer than two relevant balances exist (in that case
* the single balance is exposed via the legacy `balance` field by
* deductionToTrackResponse).
*/
export const deductionToBalancesResponse = ({
ctx,
apiCustomer,
featureDeductions,
}: {
ctx: AutumnContext;
apiCustomer: ApiCustomerV5;
featureDeductions: FeatureDeduction[];
}): Record<string, ApiBalanceV1 | null> | undefined => {
const balances: Record<string, ApiBalanceV1 | null> = {};
for (const deduction of featureDeductions) {
const relevantFeatures = getRelevantFeatures({
features: ctx.features,
featureId: deduction.feature.id,
});
for (const feature of relevantFeatures) {
balances[feature.id] = apiCustomer.balances[feature.id] ?? null;
}
}
if (Object.keys(balances).length < 2) return undefined;
return balances;
};

View File

@@ -12,10 +12,11 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
import { deductionToBalancesResponse } from "./deductionToBalancesResponse.js";
type TrackBalanceResponse = {
balance: ApiBalanceV1 | null;
balances?: Record<string, ApiBalanceV1>;
balances?: Record<string, ApiBalanceV1 | null>;
};
/**
@@ -187,23 +188,21 @@ export const deductionToTrackResponse = async ({
}
}
// 5. Return appropriate response based on number of balances
// `balances` exposes the full record of related features (main + linked
// credit systems). `balance` keeps the legacy single-feature heuristic.
const balances = deductionToBalancesResponse({
ctx,
apiCustomer,
featureDeductions,
});
if (Object.keys(finalBalances).length === 0) {
return {
balance: null,
balances: undefined,
};
return { balance: null, balances };
}
if (Object.keys(finalBalances).length === 1) {
return {
balance: Object.values(finalBalances)[0],
balances: undefined,
};
return { balance: Object.values(finalBalances)[0], balances };
}
return {
balance: null,
balances: finalBalances,
};
return { balance: null, balances };
};

View File

@@ -0,0 +1,45 @@
import {
type ApiBalanceV1,
type FullSubject,
getRelevantFeatures,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getApiSubject } from "@/internal/customers/cusUtils/getApiCustomerV2/getApiSubject.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
/**
* V2 (FullSubject) variant of deductionToBalancesResponse — returns ALL
* balances related to the tracked features (main + linked credit systems),
* with null entries for features the customer has no entitlement to.
*/
export const deductionToBalancesResponseV2 = async ({
ctx,
fullSubject,
featureDeductions,
}: {
ctx: AutumnContext;
fullSubject: FullSubject;
featureDeductions: FeatureDeduction[];
}): Promise<Record<string, ApiBalanceV1 | null> | undefined> => {
const apiSubject = await getApiSubject({
ctx,
fullSubject,
includeAggregations: true,
});
const balances: Record<string, ApiBalanceV1 | null> = {};
for (const deduction of featureDeductions) {
const relevantFeatures = getRelevantFeatures({
features: ctx.features,
featureId: deduction.feature.id,
});
for (const feature of relevantFeatures) {
balances[feature.id] = apiSubject.balances?.[feature.id] ?? null;
}
}
if (Object.keys(balances).length < 2) return undefined;
return balances;
};

View File

@@ -12,10 +12,11 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getApiSubject } from "@/internal/customers/cusUtils/getApiCustomerV2/getApiSubject.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
import { deductionToBalancesResponseV2 } from "./deductionToBalancesResponseV2.js";
type TrackBalanceResponse = {
balance: ApiBalanceV1 | null;
balances?: Record<string, ApiBalanceV1>;
balances?: Record<string, ApiBalanceV1 | null>;
};
const computeActualDeductions = ({
@@ -176,22 +177,19 @@ export const deductionToTrackResponseV2 = async ({
}
}
const balances = await deductionToBalancesResponseV2({
ctx,
fullSubject,
featureDeductions,
});
if (Object.keys(finalBalances).length === 0) {
return {
balance: null,
balances: undefined,
};
return { balance: null, balances };
}
if (Object.keys(finalBalances).length === 1) {
return {
balance: Object.values(finalBalances)[0],
balances: undefined,
};
return { balance: Object.values(finalBalances)[0], balances };
}
return {
balance: null,
balances: finalBalances,
};
return { balance: null, balances };
};

View File

@@ -1,5 +1,6 @@
export { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js";
export { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
export { deductionToBalancesResponseV2 } from "./deductionToBalancesResponseV2.js";
export { deductionToTrackResponseV2 } from "./deductionToTrackResponseV2.js";
export { executePostgresDeductionV2 } from "./executePostgresDeductionV2.js";
export { executeRedisDeductionV2 } from "./executeRedisDeductionV2.js";

View File

@@ -1,5 +1,6 @@
import { ErrCode, RecaseError } from "@autumn/shared";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2LockReceiptCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { fetchAndClaimLockReceiptV2 } from "@/internal/balances/utils/lockV2/fetchAndClaimLockReceiptV2.js";
import type { MutationLogItem } from "@/internal/balances/utils/types/mutationLogItem.js";
@@ -39,6 +40,28 @@ const normalizeLockReceiptItems = ({
});
};
const fetchAndClaimLockReceiptV2FromCandidates = async ({
ctx,
lockId,
}: {
ctx: AutumnContext;
lockId: string;
}) => {
const candidates = getRedisV2LockReceiptCandidates({ ctx });
for (const redisInstance of candidates) {
const result = await fetchAndClaimLockReceiptV2({
ctx,
lockId,
redisInstance,
});
if (result.found) return result;
}
return { found: false as const };
};
export const fetchLockReceipt = async ({
ctx,
lockId,
@@ -46,7 +69,6 @@ export const fetchLockReceipt = async ({
ctx: AutumnContext;
lockId: string;
}) => {
const { redisV2 } = ctx;
const hashedKey = Bun.hash(lockId).toString();
const lockReceiptKey = buildLockReceiptKey({
orgId: ctx.org.id,
@@ -56,6 +78,7 @@ export const fetchLockReceipt = async ({
// V2 half doubles as a fetch+claim (pipelined GET + SET NX on a marker key)
// so the dispatcher can route to runFinalizeLockV2 without a follow-up claim RT.
// During org Redis migrations, V2 checks both shared and dedicated Redis.
// V1 half stays a plain JSON.GET — V1 finalize still claims via Lua afterwards.
const [rawReceiptV1, v2Result] = await Promise.all([
tryRedisRead(
@@ -63,10 +86,9 @@ export const fetchLockReceipt = async ({
redis.call("JSON.GET", lockReceiptKey, "$") as Promise<string | null>,
redis,
),
fetchAndClaimLockReceiptV2({
fetchAndClaimLockReceiptV2FromCandidates({
ctx,
lockId,
redisInstance: redisV2,
}),
]);
@@ -76,6 +98,7 @@ export const fetchLockReceipt = async ({
lockReceiptKey: v2Result.lockReceiptKey,
source: "redis_v2" as const,
claimed: v2Result.claimed,
redisInstance: v2Result.redisInstance,
};
}

View File

@@ -1,6 +1,7 @@
import type { Feature, FullSubject } from "@autumn/shared";
import { type FinalizeLockParamsV0, findFeatureById } from "@autumn/shared";
import type { Redis } from "ioredis";
import { overrideCtxRedisV2 } from "@/external/redis/customerRedisRouting.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import type { LockReceipt } from "@/internal/balances/utils/lock/fetchLockReceipt.js";
import {
@@ -30,14 +31,18 @@ export const buildFinalizeLockContextV2 = async ({
params,
receipt,
lockReceiptKey,
redisInstance,
}: {
ctx: AutumnContext;
params: FinalizeLockParamsV0;
receipt: LockReceipt;
lockReceiptKey: string;
redisInstance: Redis;
}): Promise<FinalizeLockContextV2> => {
const ctxWithRedisV2 = overrideCtxRedisV2({ ctx, redisV2: redisInstance });
const fullSubject = await getOrSetCachedFullSubject({
ctx,
ctx: ctxWithRedisV2,
customerId: receipt.customer_id,
entityId: receipt.entity_id ?? undefined,
source: "runFinalizeLockV2",
@@ -61,7 +66,7 @@ export const buildFinalizeLockContextV2 = async ({
return {
receipt,
lockReceiptKey,
redisInstance: ctx.redisV2,
redisInstance,
fullSubject,
feature,
lockValue,

View File

@@ -21,6 +21,7 @@ type FetchAndClaimResult =
claimed: boolean;
receipt: LockReceipt;
lockReceiptKey: string;
redisInstance: Redis;
};
const normalizeLockReceiptItems = ({
@@ -118,5 +119,6 @@ export const fetchAndClaimLockReceiptV2 = async ({
claimed: claimResult === "OK",
receipt,
lockReceiptKey,
redisInstance,
};
};

View File

@@ -1,9 +1,10 @@
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
import {
CusProductStatus,
CollectionMethod,
deduplicateArray,
type ExistingUsagesConfig,
type FullCusProduct,
isFutureStartDate,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { carryOverUsagesToExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils";
@@ -49,7 +50,6 @@ export const computeAttachNewCustomerProduct = ({
endOfCycleMs,
stripeSubscription,
stripeSubscriptionSchedule,
resetCycleAnchorMs,
currentEpochMs,
featureQuantities,
trialContext,
@@ -58,6 +58,9 @@ export const computeAttachNewCustomerProduct = ({
transitionConfig,
externalId,
requestedBillingCycleAnchor,
resetCycleAnchorMs,
accessStartsAt,
paymentMethod,
} = attachBillingContext;
const currentCustomerEntitlements =
@@ -75,10 +78,13 @@ export const computeAttachNewCustomerProduct = ({
const isScheduled = planTiming === "end_of_cycle";
const startsAt = params.starts_at ?? (isScheduled ? endOfCycleMs : undefined);
const resetCycleAnchor =
resetCycleAnchorMs === "now" && params.starts_at !== undefined
? params.starts_at
: resetCycleAnchorMs;
const hasAutoChargePaymentMethod =
paymentMethod !== undefined && paymentMethod.type !== "custom";
const shouldSendInvoiceForFutureStart =
isFutureStartDate(startsAt, currentEpochMs) && !hasAutoChargePaymentMethod;
const collectionMethod = shouldSendInvoiceForFutureStart
? CollectionMethod.SendInvoice
: undefined;
let existingUsagesConfig: ExistingUsagesConfig | undefined =
!isScheduled && currentCustomerProduct
@@ -111,7 +117,7 @@ export const computeAttachNewCustomerProduct = ({
featureQuantities,
// existingUsages: isScheduled ? undefined : existingUsages,
// existingRollovers,
resetCycleAnchor,
resetCycleAnchor: resetCycleAnchorMs,
now: currentEpochMs,
freeTrial: trialContext?.freeTrial ?? null,
trialEndsAt: trialContext?.trialEndsAt ?? undefined,
@@ -126,8 +132,10 @@ export const computeAttachNewCustomerProduct = ({
// subscriptionId: isScheduled ? undefined : stripeSubscription?.id,
subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
status: isScheduled ? CusProductStatus.Scheduled : undefined,
startsAt,
endedAt: params.ends_at,
accessStartsAt,
collectionMethod,
externalId,
billingCycleAnchorResetsAt: getScheduledBillingCycleAnchorResetAt({
requestedBillingCycleAnchor,

View File

@@ -46,6 +46,7 @@ export const computeAttachPlan = ({
const updateCustomerProduct = computeAttachTransitionUpdates({
attachBillingContext,
params,
});
const {
@@ -60,6 +61,7 @@ export const computeAttachPlan = ({
const shouldBuildLineItems = shouldBuildImmediateLineItems({
planTiming,
customerProductStatus: newCustomerProduct.status,
accessStartsAt: attachBillingContext.accessStartsAt,
});
const { allLineItems: lineItems, updateCustomerEntitlements } =

View File

@@ -1,5 +1,9 @@
import type { AttachBillingContext, AutumnBillingPlan } from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
import type {
AttachBillingContext,
AttachParamsV1,
AutumnBillingPlan,
} from "@autumn/shared";
import { CusProductStatus, isFutureStartDate } from "@autumn/shared";
/**
* Computes the updates to apply to the current customer product during an attach transition.
@@ -9,8 +13,10 @@ import { CusProductStatus } from "@autumn/shared";
*/
export const computeAttachTransitionUpdates = ({
attachBillingContext,
params = {} as AttachParamsV1,
}: {
attachBillingContext: AttachBillingContext;
params?: AttachParamsV1;
}): AutumnBillingPlan["updateCustomerProduct"] => {
const { currentCustomerProduct, planTiming, currentEpochMs, endOfCycleMs } =
attachBillingContext;
@@ -34,7 +40,12 @@ export const computeAttachTransitionUpdates = ({
};
}
// Downgrade: mark as canceling at end of cycle
const startsAt = params.starts_at;
const transitionAtMs = isFutureStartDate(startsAt, currentEpochMs)
? startsAt
: endOfCycleMs;
// Downgrade: mark as canceling when the scheduled replacement starts.
return {
customerProduct: currentCustomerProduct,
updates: {
@@ -43,7 +54,7 @@ export const computeAttachTransitionUpdates = ({
: undefined,
canceled: true,
canceled_at: currentEpochMs,
ended_at: endOfCycleMs,
ended_at: transitionAtMs,
},
};
};

View File

@@ -3,10 +3,13 @@ import { type AttachBillingContext, CusProductStatus } from "@autumn/shared";
export const shouldBuildImmediateLineItems = ({
planTiming,
customerProductStatus,
accessStartsAt,
}: {
planTiming: AttachBillingContext["planTiming"];
customerProductStatus: CusProductStatus;
accessStartsAt?: number;
}): boolean => {
if (accessStartsAt !== undefined) return false;
if (planTiming !== "immediate") return false;
return customerProductStatus !== CusProductStatus.Scheduled;
};

View File

@@ -9,6 +9,7 @@ import { handleBillingCycleAnchorErrors } from "@/internal/billing/v2/actions/at
import { handleCarryOverBalancesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverBalancesErrors";
import { handleCarryOverUsagesErrors } from "@/internal/billing/v2/actions/attach/errors/handleCarryOverUsagesErrors";
import { handleCurrentCustomerProductErrors } from "@/internal/billing/v2/actions/attach/errors/handleCurrentCustomerProductErrors";
import { handleEndDateErrors } from "@/internal/billing/v2/actions/attach/errors/handleEndDateErrors";
import { handleNewBillingSubscriptionErrors } from "@/internal/billing/v2/actions/attach/errors/handleNewBillingSubscriptionErrors";
import { handleScheduledSwitchOneOffErrors } from "@/internal/billing/v2/actions/attach/errors/handleScheduledSwitchOneOffErrors";
import { handleStartDateErrors } from "@/internal/billing/v2/actions/attach/errors/handleStartDateErrors";
@@ -60,6 +61,7 @@ export const handleAttachV2Errors = async ({
handleScheduledSwitchOneOffErrors({ ctx, billingContext });
handleBillingCycleAnchorErrors({ billingContext });
handleStartDateErrors({ billingContext, params });
handleEndDateErrors({ billingContext, params });
// 7. Transition config errors (reset_after_trial_end on allocated features)
handleTransitionConfigErrors({ ctx, billingContext });

View File

@@ -0,0 +1,53 @@
import {
type AttachBillingContext,
type AttachParamsV1,
ErrCode,
isFreeProduct,
isOneOffProduct,
isPastStartDate,
RecaseError,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
export const handleEndDateErrors = ({
billingContext,
params,
}: {
billingContext: AttachBillingContext;
params: AttachParamsV1;
}) => {
if (params.ends_at === undefined) return;
if (isPastStartDate(params.ends_at, billingContext.currentEpochMs)) {
throw new RecaseError({
message:
"ends_at cannot be set to a past timestamp. Use a future Unix timestamp in milliseconds.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
const startsAt =
params.starts_at ??
(billingContext.planTiming === "end_of_cycle"
? billingContext.endOfCycleMs
: billingContext.currentEpochMs);
if (startsAt !== undefined && params.ends_at <= startsAt) {
throw new RecaseError({
message: "ends_at must be after the plan start timestamp.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
const prices = billingContext.attachProduct.prices;
const isPaidRecurring =
!isFreeProduct({ prices }) && !isOneOffProduct({ prices });
if (!isPaidRecurring) {
throw new RecaseError({
message: "ends_at is only supported for paid recurring plans.",
code: ErrCode.InvalidRequest,
statusCode: StatusCodes.BAD_REQUEST,
});
}
};

View File

@@ -0,0 +1,20 @@
import { type AttachParamsV1, isFutureStartDate } from "@autumn/shared";
export const getAttachAccessStartsAt = ({
params,
currentEpochMs,
}: {
params: AttachParamsV1;
currentEpochMs: number;
}): number | undefined => {
const startsAt = params.starts_at;
if (
params.enable_plan_immediately !== true ||
startsAt === undefined ||
!isFutureStartDate(startsAt, currentEpochMs)
) {
return undefined;
}
return currentEpochMs;
};

View File

@@ -25,6 +25,7 @@ import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCyc
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
import { getAttachAccessStartsAt } from "./getAttachAccessStartsAt";
import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode";
import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs";
import { setupAttachProductContext } from "./setupAttachProductContext";
@@ -182,12 +183,6 @@ export const setupAttachBillingContext = async ({
billingCycleAnchorMs = trialContext.trialEndsAt;
}
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
newFullProduct: attachProduct,
});
const endOfCycleMs =
contextOverride.endOfCycleMsOverride ??
setupAttachEndOfCycleMs({
@@ -198,10 +193,23 @@ export const setupAttachBillingContext = async ({
currentEpochMs,
});
const billingStartsAt =
params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined);
const hasFutureStartDate = isFutureStartDate(
params.starts_at,
currentEpochMs,
);
const accessStartsAt = getAttachAccessStartsAt({
params,
currentEpochMs,
});
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
newFullProduct: attachProduct,
billingStartsAt,
});
const checkoutMode = setupAttachCheckoutMode({
paymentMethod,
@@ -250,6 +258,7 @@ export const setupAttachBillingContext = async ({
invoiceMode,
enablePlanImmediately: params.enable_plan_immediately ?? false,
accessStartsAt,
customPrices,
customEnts,

View File

@@ -36,10 +36,6 @@ export const setupAttachCheckoutMode = ({
const productIsFree = isFreeProduct({ prices });
const productIsPaidRecurring = !productIsOneOff && !productIsFree;
if (hasFutureStartDate && !hasPaymentMethod) {
return "stripe_checkout";
}
if (redirectMode === "never") {
return null;
}
@@ -48,6 +44,10 @@ export const setupAttachCheckoutMode = ({
return null;
}
if (hasFutureStartDate) {
return null;
}
const getStripeCheckoutOrDirectBilling = () => {
// A. if no payment method
if (hasPaymentMethod) return null;

View File

@@ -1,5 +1,8 @@
import type { FullCusProduct, LineItem } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import type {
AutumnBillingPlan,
FullCusProduct,
LineItem,
} from "@autumn/shared";
import type { CancelUpdates } from "./computeCancelUpdates";
/**
@@ -14,6 +17,7 @@ export const applyCancelPlan = ({
cancelUpdates,
defaultCustomerProduct,
productToDelete,
productsToDelete,
cancelLineItems,
existingCustomerProduct,
}: {
@@ -21,6 +25,7 @@ export const applyCancelPlan = ({
cancelUpdates: CancelUpdates;
defaultCustomerProduct: FullCusProduct | undefined;
productToDelete: FullCusProduct | undefined;
productsToDelete: FullCusProduct[];
cancelLineItems: LineItem[];
existingCustomerProduct: FullCusProduct;
}): AutumnBillingPlan => {
@@ -61,6 +66,13 @@ export const applyCancelPlan = ({
plan.deleteCustomerProduct = productToDelete;
}
if (productsToDelete.length > 0) {
plan.deleteCustomerProducts = [
...(plan.deleteCustomerProducts ?? []),
...productsToDelete,
];
}
// Merge cancel line items (prorated refunds for immediate cancellation)
if (cancelLineItems.length > 0) {
plan.lineItems = [...(plan.lineItems ?? []), ...cancelLineItems];

View File

@@ -1,6 +1,10 @@
import {
type AutumnBillingPlan,
cp,
type FullCusProduct,
type UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import { applyUncancelToPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/cancel/applyUncancelToPlan";
import { applyCancelPlan } from "./applyCancelPlan";
import { computeCancelLineItems } from "./computeCancelLineItems";
@@ -9,6 +13,32 @@ import { computeCustomerProductToDelete } from "./computeCustomerProductToDelete
import { computeDefaultCustomerProduct } from "./computeDefaultCustomerProduct";
import { computeEndOfCycleMs } from "./computeEndOfCycleMs";
const computeScheduledAddOnsToDelete = ({
billingContext,
}: {
billingContext: UpdateSubscriptionBillingContext;
}): FullCusProduct[] => {
// Immediate main-plan cancellation invalidates future add-on phases in the same scope.
const { cancelAction, customerProduct, fullCustomer } = billingContext;
if (cancelAction !== "cancel_immediately") return [];
if (!cp(customerProduct).main().recurring().valid) return [];
const internalEntityId =
customerProduct.internal_entity_id ??
fullCustomer.entity?.internal_id ??
undefined;
return fullCustomer.customer_products.filter((candidateProduct) => {
if (candidateProduct.id === customerProduct.id) return false;
return cp(candidateProduct)
.addOn()
.scheduled()
.recurring()
.onEntity({ internalEntityId }).valid;
});
};
/**
* Computes and applies the cancel plan for a subscription.
*
@@ -57,6 +87,7 @@ export const computeCancelPlan = ({
// Step 4: Find existing scheduled product to delete
const productToDelete = computeCustomerProductToDelete({ billingContext });
const productsToDelete = computeScheduledAddOnsToDelete({ billingContext });
// Step 5: Compute prorated refund line items for immediate cancellation
const cancelLineItems = computeCancelLineItems({ ctx, billingContext });
@@ -67,6 +98,7 @@ export const computeCancelPlan = ({
cancelUpdates,
defaultCustomerProduct,
productToDelete,
productsToDelete,
cancelLineItems,
existingCustomerProduct: billingContext.customerProduct,
});

View File

@@ -0,0 +1,59 @@
import type {
AutumnBillingPlan,
BillingContext,
FullCusProduct,
} from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
/**
* Builds the Stripe-facing product timeline when Autumn access starts before billing.
*/
export const buildCustomerProductsForStripe = ({
billingContext,
autumnBillingPlan,
finalCustomerProducts,
}: {
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
finalCustomerProducts: FullCusProduct[];
}): FullCusProduct[] => {
if (billingContext.accessStartsAt === undefined) return finalCustomerProducts;
const insertedCustomerProductIds = new Set(
autumnBillingPlan.insertCustomerProducts.map(
(customerProduct) => customerProduct.id,
),
);
const billingStartMs = autumnBillingPlan.insertCustomerProducts.find(
(customerProduct) =>
customerProduct.access_starts_at !== undefined &&
customerProduct.access_starts_at !== null,
)?.starts_at;
if (billingStartMs === undefined) return finalCustomerProducts;
const outgoingCustomerProduct =
autumnBillingPlan.updateCustomerProduct?.customerProduct;
return finalCustomerProducts.map((customerProduct) => {
if (insertedCustomerProductIds.has(customerProduct.id)) {
return {
...customerProduct,
status: CusProductStatus.Scheduled,
starts_at: billingStartMs,
};
}
if (outgoingCustomerProduct?.id === customerProduct.id) {
return {
...outgoingCustomerProduct,
status: CusProductStatus.Active,
ended_at: billingStartMs,
canceled: true,
canceled_at: billingContext.currentEpochMs,
};
}
return customerProduct;
});
};

View File

@@ -79,8 +79,8 @@ export const buildStripeCheckoutSessionAction = ({
// 7. Build params. Tax policy is baked in here (not at execute time) so
// the action object is self-describing in logs/EXTRA_LOGS.
const autumnAutoTax: Partial<Stripe.Checkout.SessionCreateParams> = org
.config.automatic_tax
const autumnAutoTax: Partial<Stripe.Checkout.SessionCreateParams> = org.config
.automatic_tax
? {
automatic_tax: { enabled: true },
billing_address_collection: "required",

View File

@@ -9,6 +9,7 @@ import type {
} from "@autumn/shared";
import { orgToCurrency } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildCustomerProductsForStripe } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildCustomerProductsForStripe";
import { buildStripeRefundAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeRefundAction.js";
import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction";
import { shouldCreateManualStripeInvoice } from "@/internal/billing/v2/providers/stripe/utils/invoices/shouldCreateManualStripeInvoice";
@@ -44,6 +45,11 @@ export const evaluateStripeBillingPlan = async ({
billingContext,
autumnBillingPlan,
});
const stripeCustomerProducts = buildCustomerProductsForStripe({
billingContext,
autumnBillingPlan,
finalCustomerProducts: finalFullCustomer.customer_products,
});
// Build stripe subscription schedule action
const {
@@ -54,7 +60,7 @@ export const evaluateStripeBillingPlan = async ({
ctx,
billingContext,
autumnBillingPlan,
finalCustomerProducts: finalFullCustomer.customer_products,
finalCustomerProducts: stripeCustomerProducts,
trialEndsAt: billingContext.trialContext?.trialEndsAt ?? undefined,
});
@@ -62,7 +68,7 @@ export const evaluateStripeBillingPlan = async ({
ctx,
billingContext,
autumnBillingPlan,
finalCustomerProducts: finalFullCustomer.customer_products,
finalCustomerProducts: stripeCustomerProducts,
stripeSubscriptionScheduleAction,
subscriptionCancelAt,
subscriptionStartsAt,

View File

@@ -136,6 +136,26 @@ const createScheduleFromSubscription = async ({
});
};
const getStandaloneScheduleDefaults = ({
billingContext,
}: {
billingContext: BillingContext;
}): Partial<Stripe.SubscriptionScheduleCreateParams> => {
const paymentMethod = billingContext.paymentMethod;
const shouldSendInvoice = !paymentMethod || paymentMethod.type === "custom";
if (!shouldSendInvoice) return {};
return {
default_settings: {
collection_method: "send_invoice",
invoice_settings: {
days_until_due: 30,
},
},
};
};
export const executeStripeSubscriptionScheduleAction = async ({
ctx,
billingContext,
@@ -181,6 +201,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
phases: params.phases?.map(toCreatePhase) ?? [],
end_behavior: params.end_behavior,
start_date: startDate,
...getStandaloneScheduleDefaults({ billingContext }),
});
}

View File

@@ -114,8 +114,9 @@ const applyAdjustableQuantityToPrepaidLineItem = ({
}
const feature = autumnEntitlement.feature;
const isAdjustable =
billingContext.adjustableFeatureQuantities?.includes(feature.id);
const isAdjustable = billingContext.adjustableFeatureQuantities?.includes(
feature.id,
);
if (!isAdjustable) {
return lineItem;

View File

@@ -1,6 +1,6 @@
import { EntityNotFoundError } from "@autumn/shared";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { CusService } from "@server/internal/customers/CusService";
import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer";
export const setupFullCustomerContext = async ({
ctx,
@@ -11,12 +11,11 @@ export const setupFullCustomerContext = async ({
}) => {
const { customer_id: customerId } = params;
const fullCustomer = await CusService.getFull({
const fullCustomer = await getOrSetCachedFullCustomer({
ctx,
idOrInternalId: customerId,
withSubs: true,
withEntities: true,
customerId,
entityId: params.entity_id ?? undefined,
source: "setupFullCustomerContext",
});
if (params.entity_id && !fullCustomer.entity) {

View File

@@ -8,16 +8,28 @@ import {
/**
* Determine the billing cycle anchor based on product transitions.
*
* For future starts, feature resets anchor to the billing start (`startsAt`).
*/
export const setupResetCycleAnchor = ({
billingCycleAnchorMs,
customerProduct,
newFullProduct,
billingStartsAt,
}: {
billingCycleAnchorMs: number | "now";
customerProduct?: FullCusProduct;
newFullProduct: FullProduct;
billingStartsAt?: number;
}): number | "now" => {
const hasFutureBillingStart = billingStartsAt !== undefined;
const shouldAnchorToBillingStart =
hasFutureBillingStart && !customerProduct;
if (shouldAnchorToBillingStart) {
return billingStartsAt;
}
if (!customerProduct) {
return billingCycleAnchorMs;
}

View File

@@ -30,6 +30,52 @@ export type NextCyclePreviewResult = {
debug: NextCyclePreviewDebug;
};
const getScheduledStartPreviewContext = ({
customerProducts,
currentCustomerProducts,
currentEpochMs,
}: {
customerProducts: FullCusProduct[];
currentCustomerProducts: FullCusProduct[];
currentEpochMs: number;
}) => {
let scheduledStartMs: number | null = null;
for (const customerProduct of customerProducts) {
if (!cp(customerProduct).scheduled().valid) continue;
if (customerProduct.starts_at <= currentEpochMs) continue;
scheduledStartMs =
scheduledStartMs === null
? customerProduct.starts_at
: Math.min(scheduledStartMs, customerProduct.starts_at);
}
const scheduledStartCustomerProducts =
scheduledStartMs === null
? []
: customerProducts.filter(
(customerProduct) => customerProduct.starts_at === scheduledStartMs,
);
const currentPrices = cusProductsToPrices({
cusProducts: currentCustomerProducts,
filters: { excludeOneOffPrices: true },
});
const scheduledStartPrices = cusProductsToPrices({
cusProducts: scheduledStartCustomerProducts,
filters: { excludeOneOffPrices: true },
});
return {
scheduledStartMs,
scheduledStartCustomerProducts,
smallestInterval: getSmallestInterval({
prices: currentPrices.length > 0 ? currentPrices : scheduledStartPrices,
}),
};
};
export const billingPlanToNextCyclePreview = ({
ctx,
billingContext,
@@ -62,13 +108,16 @@ export const billingPlanToNextCyclePreview = ({
cp(customerProduct).paid().recurring().hasActiveStatus().valid,
);
const currentPrices = cusProductsToPrices({
cusProducts: currentCustomerProducts,
filters: { excludeOneOffPrices: true },
const {
scheduledStartMs,
scheduledStartCustomerProducts,
smallestInterval,
} = getScheduledStartPreviewContext({
customerProducts,
currentCustomerProducts,
currentEpochMs: billingContext.currentEpochMs,
});
const smallestInterval = getSmallestInterval({ prices: currentPrices });
// Calculate anchor
const anchorMs =
billingCycleAnchorMs === "now"
@@ -82,7 +131,7 @@ export const billingPlanToNextCyclePreview = ({
anchorMs,
};
if (billingCycleAnchorMs === "now") {
if (billingCycleAnchorMs === "now" && scheduledStartMs === null) {
return {
nextCycle: undefined,
debug: {
@@ -120,6 +169,8 @@ export const billingPlanToNextCyclePreview = ({
nextCycleStart = result.nextCycleStart;
prorationRatio = result.prorationRatio;
lineItemsBillingContext = result.lineItemsBillingContext;
} else if (billingCycleAnchorMs === "now" && scheduledStartMs !== null) {
nextCycleStart = scheduledStartMs;
} else {
nextCycleStart = getCycleEnd({
anchor: anchorMs,
@@ -130,7 +181,11 @@ export const billingPlanToNextCyclePreview = ({
});
}
const filteredCustomerProducts = customerProducts.filter(
const nextCycleCustomerProducts =
billingCycleAnchorMs === "now" && scheduledStartMs !== null
? scheduledStartCustomerProducts
: customerProducts;
const filteredCustomerProducts = nextCycleCustomerProducts.filter(
(customerProduct) => {
return !hasCustomerProductEnded(customerProduct, {
nowMs: nextCycleStart,

View File

@@ -19,6 +19,15 @@ import { cusProductToBalances } from "@/internal/customers/cusUtils/apiCusUtils/
import { getApiSubscription } from "@/internal/customers/cusUtils/apiCusUtils/getApiSubscription/getApiSubscription.js";
import { billingPlanToOutgoingEffectiveAt } from "./billingPlanToEffectiveAt";
function getIncomingEffectiveAt({
customerProduct,
}: {
customerProduct: FullCusProduct;
}): number | null {
if (customerProduct.status !== CusProductStatus.Scheduled) return null;
return customerProduct.starts_at ?? null;
}
function cusProductToFeatureQuantities({
ctx,
cusProduct,
@@ -161,7 +170,7 @@ export const billingPlanToChanges = async ({
ctx: incomingCtx,
cusProduct,
}),
effective_at: null,
effective_at: getIncomingEffectiveAt({ customerProduct: cusProduct }),
canceled_at: subscription.canceled_at,
expires_at: subscription.expires_at,
});
@@ -202,7 +211,9 @@ export const billingPlanToChanges = async ({
ctx: incomingCtx,
cusProduct: updatedCustomerProduct,
}),
effective_at: null,
effective_at: getIncomingEffectiveAt({
customerProduct: updatedCustomerProduct,
}),
canceled_at: subscription.canceled_at,
expires_at: subscription.expires_at,
});

View File

@@ -35,6 +35,7 @@ export const initCustomerProduct = ({
apiSemver,
externalId,
billingCycleAnchorResetsAt,
accessStartsAt,
} = initOptions ?? {};
const internalEntityId = fullCustomer.entity?.internal_id;
@@ -48,7 +49,11 @@ export const initCustomerProduct = ({
// 1 minute tolerance to determine if customer product should be scheduled. (for test clock time frozen issues)
const TOLERANCE_MS = ms.minutes(1);
if (startsAt && startsAt > now + TOLERANCE_MS) {
const effectiveAccessStartsAt = accessStartsAt ?? startsAt;
if (
effectiveAccessStartsAt &&
effectiveAccessStartsAt > now + TOLERANCE_MS
) {
return CusProductStatus.Scheduled;
}
@@ -85,6 +90,7 @@ export const initCustomerProduct = ({
// processor: null,
starts_at: startsAt,
access_starts_at: accessStartsAt ?? null,
ended_at: endedAt,
trial_ends_at: trialEndsAt,

View File

@@ -12,6 +12,7 @@ import {
import type { Context, Next } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import { StatusCodes } from "http-status-codes";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { HonoEnv } from "@/honoUtils/HonoEnv";
import { checkoutActions } from "@/internal/checkouts/actions";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -117,8 +118,7 @@ export const checkoutMiddleware = async (c: Context<HonoEnv>, next: Next) => {
});
}
// Set up context with org/env/features for handlers
c.set("ctx", {
const nextCtx = {
...ctx,
org: orgWithFeatures.org,
env,
@@ -129,7 +129,11 @@ export const checkoutMiddleware = async (c: Context<HonoEnv>, next: Next) => {
orgId: orgWithFeatures.org.id,
customerId: validCheckout.customer_id,
}),
});
};
const { ctx: routedCtx } = getCtxWithCustomerRedis({ ctx: nextCtx });
// Set up context with org/env/features for handlers
c.set("ctx", routedCtx);
// Attach checkout to context for handlers
c.set("checkout", validCheckout);

View File

@@ -21,7 +21,10 @@ import {
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { RepoContext } from "@/db/repoContext.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import {
getCustomerRedisRoutingId,
resolveCustomerRedisRouting,
} from "@/external/redis/customerRedisRouting.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -363,7 +366,10 @@ export const createFullCusProduct = async ({
},
env: customer.env,
logger,
redisV2: resolveRedisV2(),
redisV2: resolveCustomerRedisRouting({
org,
customerId: getCustomerRedisRoutingId({ customer }),
}).redis,
};
if (

View File

@@ -1,4 +1,5 @@
import { type FullSubject, normalizedToFullSubject } from "@autumn/shared";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
@@ -154,6 +155,28 @@ export const getCachedFullSubject = async ({
};
}
if (
isRedisMigrationCacheStale({
cachedAt: cached._cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
) {
logger.warn(
`[getCachedFullSubject] Stale Redis migration cache for ${customerId}${entityId ? `:${entityId}` : ""}, evicting`,
);
await invalidateCachedFullSubject({
ctx,
customerId,
entityId,
source: "stale-redis-migration",
});
return {
fullSubject: undefined,
subjectViewEpoch: currentSubjectViewEpoch,
};
}
const isCustomerSubject = !entityId;
const balancesOutcome = await getCachedFeatureBalancesBatch({
ctx,

View File

@@ -19,7 +19,7 @@ type BatchInvalidateCustomer = {
type FeaturesByOrgEnv = Record<string, Feature[]>;
export const batchInvalidateCachedFullSubjects = async ({
const batchInvalidateCachedFullSubjectsOnRedis = async ({
customers,
featuresByOrgEnv,
redisV2,
@@ -27,11 +27,8 @@ export const batchInvalidateCachedFullSubjects = async ({
customers: BatchInvalidateCustomer[];
featuresByOrgEnv: FeaturesByOrgEnv;
redisV2: Redis;
}): Promise<number> => {
if (customers.length === 0) return 0;
const deleted = await batchDeleteCachedFullCustomers({ customers });
if (redisV2.status !== "ready") return deleted;
}): Promise<void> => {
if (customers.length === 0 || redisV2.status !== "ready") return;
for (
let offset = 0;
@@ -98,6 +95,45 @@ export const batchInvalidateCachedFullSubjects = async ({
await tryRedisWrite(() => writePipeline.exec(), redisV2);
}
};
export const batchInvalidateCachedFullSubjects = async ({
customers,
featuresByOrgEnv,
getRedisTargetsForCustomer,
}: {
customers: BatchInvalidateCustomer[];
featuresByOrgEnv: FeaturesByOrgEnv;
getRedisTargetsForCustomer: ({
customer,
}: {
customer: BatchInvalidateCustomer;
}) => Redis[];
}): Promise<number> => {
if (customers.length === 0) return 0;
const deleted = await batchDeleteCachedFullCustomers({ customers });
const customersByRedis = new Map<Redis, BatchInvalidateCustomer[]>();
for (const customer of customers) {
for (const targetRedis of new Set(
getRedisTargetsForCustomer({ customer }),
)) {
const existing = customersByRedis.get(targetRedis) ?? [];
existing.push(customer);
customersByRedis.set(targetRedis, existing);
}
}
await Promise.all(
[...customersByRedis.entries()].map(([targetRedis, redisCustomers]) =>
batchInvalidateCachedFullSubjectsOnRedis({
customers: redisCustomers,
featuresByOrgEnv,
redisV2: targetRedis,
}),
),
);
return deleted;
};

View File

@@ -1,3 +1,5 @@
import type { Redis } from "ioredis";
import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js";
import { tryRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -5,30 +7,29 @@ import { buildFullSubjectViewEpochKey } from "../../builders/buildFullSubjectVie
import { FULL_SUBJECT_EPOCH_TTL_SECONDS } from "../../config/fullSubjectCacheConfig.js";
import { invalidateSharedBalanceFields } from "./invalidateSharedBalanceFields.js";
export const invalidateCachedFullSubject = async ({
const invalidateCachedFullSubjectOnRedis = async ({
customerId,
entityId,
ctx,
source,
redisV2,
}: {
customerId: string;
entityId?: string;
ctx: AutumnContext;
source?: string;
redisV2: Redis;
}): Promise<void> => {
if (!customerId) return;
if (redisV2.status !== "ready") return;
await invalidateSharedBalanceFields({
ctx,
customerId,
redisV2,
});
const { org, env, logger, redisV2 } = ctx;
const { org, env, logger } = ctx;
// All four ops share the `{customerId}` hash tag so they land on the same
// Redis slot. Bundling them into a single pipeline collapses what used to
// be 34 sequential RTTs (UNLINK subject + optional UNLINK entity subject
// + INCR epoch + EXPIRE epoch) into one.
const customerSubjectKey = buildFullSubjectKey({
orgId: org.id,
env,
@@ -67,3 +68,32 @@ export const invalidateCachedFullSubject = async ({
);
}
};
export const invalidateCachedFullSubject = async ({
customerId,
entityId,
ctx,
source,
}: {
customerId: string;
entityId?: string;
ctx: AutumnContext;
source?: string;
}): Promise<void> => {
if (!customerId) return;
await Promise.all(
getRedisTargetsForCustomer({
org: ctx.org,
currentRedis: ctx.redisV2,
}).map((redisV2) =>
invalidateCachedFullSubjectOnRedis({
customerId,
entityId,
ctx,
source,
redisV2,
}),
),
);
};

View File

@@ -1,7 +1,52 @@
import type { Redis } from "ioredis";
import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js";
import { tryRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
const invalidateCachedFullSubjectExactOnRedis = async ({
customerId,
entityId,
ctx,
source,
redisV2,
}: {
customerId: string;
entityId?: string;
ctx: AutumnContext;
source?: string;
redisV2: Redis;
}): Promise<void> => {
if (redisV2.status !== "ready") return;
const { org, env, logger } = ctx;
const subjectKey = buildFullSubjectKey({
orgId: org.id,
env,
customerId,
entityId,
});
const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId;
const result = await tryRedisOp({
operation: () => redisV2.unlink(subjectKey),
source: "invalidateCachedFullSubjectExact",
redisInstance: redisV2,
onError: (error: unknown) => {
logger.error(
`[invalidateCachedFullSubjectExact] subject: ${subjectLabel}, source: ${source}, error: ${error}`,
);
},
});
if (result !== undefined) {
logger.info(
`[invalidateCachedFullSubjectExact] subject: ${subjectLabel}, source: ${source}`,
);
}
};
export const invalidateCachedFullSubjectExact = async ({
customerId,
entityId,
@@ -13,28 +58,20 @@ export const invalidateCachedFullSubjectExact = async ({
ctx: AutumnContext;
source?: string;
}): Promise<void> => {
const { org, env, logger, redisV2 } = ctx;
if (!customerId || redisV2.status !== "ready") return;
if (!customerId) return;
const subjectKey = buildFullSubjectKey({
orgId: org.id,
env,
customerId,
entityId,
});
const subjectLabel = entityId ? `${customerId}:${entityId}` : customerId;
try {
await tryRedisWrite(async () => {
await redisV2.unlink(subjectKey);
}, redisV2);
logger.info(
`[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}`,
);
} catch (error) {
logger.error(
`[invalidateCachedFullSubject] subject: ${subjectLabel}, source: ${source}, error: ${error}`,
);
}
await Promise.all(
getRedisTargetsForCustomer({
org: ctx.org,
currentRedis: ctx.redisV2,
}).map((redisV2) =>
invalidateCachedFullSubjectExactOnRedis({
customerId,
entityId,
ctx,
source,
redisV2,
}),
),
);
};

View File

@@ -1,3 +1,4 @@
import type { Redis } from "ioredis";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { tryRedisRead, tryRedisWrite } from "@/utils/cacheUtils/cacheUtils.js";
import { buildFullSubjectKey } from "../../builders/buildFullSubjectKey.js";
@@ -17,11 +18,13 @@ import type { CachedFullSubject } from "../../fullSubjectCacheModel.js";
export const invalidateSharedBalanceFields = async ({
ctx,
customerId,
redisV2 = ctx.redisV2,
}: {
ctx: AutumnContext;
customerId: string;
redisV2?: Redis;
}): Promise<void> => {
const { org, env, redisV2 } = ctx;
const { org, env } = ctx;
if (!customerId || redisV2.status !== "ready") return;
const subjectKey = buildFullSubjectKey({ orgId: org.id, env, customerId });
@@ -29,19 +32,21 @@ export const invalidateSharedBalanceFields = async ({
const cachedRaw = await tryRedisRead(() => redisV2.get(subjectKey), redisV2);
if (!cachedRaw) return;
await deleteFieldsFromManifest({ ctx, customerId, cachedRaw });
await deleteFieldsFromManifest({ ctx, customerId, cachedRaw, redisV2 });
};
async function deleteFieldsFromManifest({
ctx,
customerId,
cachedRaw,
redisV2,
}: {
ctx: AutumnContext;
customerId: string;
cachedRaw: string;
redisV2: Redis;
}) {
const { org, env, logger, redisV2 } = ctx;
const { org, env, logger } = ctx;
let manifest: CachedFullSubject;
try {

View File

@@ -1,5 +1,6 @@
import type { FullSubject } from "@autumn/shared";
import { normalizedToFullSubject } from "@autumn/shared";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { runRedisOp } from "@/external/redis/utils/runRedisOp.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { lazyResetSubjectEntitlements } from "@/internal/customers/actions/resetCustomerEntitlementsV2/lazyResetSubjectEntitlements.js";
@@ -185,6 +186,32 @@ export const getCachedPartialFullSubject = async ({
};
}
const redisMigrationOk = await tryOrInvalidate({
ctx,
operation: () =>
isRedisMigrationCacheStale({
cachedAt: cached._cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
? undefined
: true,
invalidate: () =>
invalidateCachedFullSubject({
ctx,
customerId,
entityId,
source: "partial-stale-redis-migration",
}),
warnMessage: `[getCachedPartialFullSubject] Stale Redis migration cache for ${subjectLabel}, evicting`,
});
if (redisMigrationOk === undefined) {
return {
fullSubject: undefined,
subjectViewEpoch: currentSubjectViewEpoch,
};
}
const meteredFeatureIdsToFetch = featureIds.filter((featureId) =>
cached.meteredFeatures.includes(featureId),
);

View File

@@ -31,8 +31,13 @@ export const getRolloverUpdates = ({
toUpdate: [],
};
const ent = cusEnt.entitlement;
const shouldRollover =
cusEnt.balance && cusEnt.balance > 0 && notNullish(ent.rollover);
const hasEntityBalanceToRollover =
notNullish(ent.entity_feature_id) &&
Object.values(cusEnt.entities ?? {}).some((entity) => entity.balance > 0);
const hasBalanceToRollover = notNullish(ent.entity_feature_id)
? hasEntityBalanceToRollover
: cusEnt.balance != null && cusEnt.balance > 0;
const shouldRollover = hasBalanceToRollover && notNullish(ent.rollover);
if (!shouldRollover) return update;
@@ -176,21 +181,6 @@ export function performMaximumClearing({
return { toDelete, toUpdate };
}
const allEntityIds = new Set<string>();
rows.forEach((row) => {
if (row.entities && Array.isArray(row.entities)) {
row.entities.forEach((entity: any) => {
if (entity.id) {
allEntityIds.add(entity.id);
}
});
}
});
const entityTotals = new Map<string, number>();
allEntityIds.forEach((id) => {
entityTotals.set(id, 0);
});
const entityIdToTotal: Record<string, number> = {};
rows.forEach((row) => {
for (const entityId in row.entities) {

View File

@@ -8,6 +8,7 @@ import {
import { Decimal } from "decimal.js";
import type { Redis } from "ioredis";
import { getDbHealth, PgHealth } from "@/db/pgHealthMonitor.js";
import { isRedisMigrationCacheStale } from "@/external/redis/customerRedisRouting.js";
import { redis } from "@/external/redis/initRedis.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { isSnapshotCacheStale } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -172,6 +173,25 @@ export const getCachedFullCustomer = async ({
return undefined;
}
if (
isRedisMigrationCacheStale({
cachedAt,
customerId,
redisConfig: ctx.org.redis_config,
})
) {
ctx.logger.warn(
`[getCachedFullCustomer] Stale Redis migration cache for ${customerId}, evicting`,
);
await deleteCachedFullCustomer({
ctx,
customerId,
source: "stale-redis-migration",
skipGuard: true,
});
return undefined;
}
const fullCustomer = normalizeFromSchema<FullCustomer>({
schema: FullCustomerSchema,
data: parsed,

View File

@@ -1,5 +1,6 @@
import { orgToFeaturesByOrgEnv, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js";
import { createRoute } from "../../../honoMiddlewares/routeHandler";
import { batchInvalidateCachedFullSubjects } from "../cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects";
import { deleteCachedFullCustomer } from "../cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
@@ -37,7 +38,10 @@ export const handleClearCustomerCache = createRoute({
await batchInvalidateCachedFullSubjects({
customers: customersToDelete,
featuresByOrgEnv,
redisV2: ctx.redisV2,
getRedisTargetsForCustomer: () =>
getRedisTargetsForCustomer({
org: ctx.org,
}),
});
}

View File

@@ -8,7 +8,7 @@ import {
} from "@autumn/shared";
import { and, asc, count, eq, gt, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { getRedisTargetsForCustomer } from "@/external/redis/customerRedisRouting.js";
import { batchInvalidateCachedFullSubjects } from "@/internal/customers/cache/fullSubject/actions/invalidate/batchInvalidateCachedFullSubjects.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import type { Logger } from "../../../external/logtail/logtailUtils";
@@ -165,7 +165,10 @@ export const runClearCreditSystemCacheTask = async ({
const deleted = await batchInvalidateCachedFullSubjects({
customers: customersToDelete,
featuresByOrgEnv,
redisV2: resolveRedisV2(),
getRedisTargetsForCustomer: () =>
getRedisTargetsForCustomer({
org: orgWithFeatures.org,
}),
});
totalDeleted += deleted;
}

View File

@@ -5,6 +5,7 @@ import {
type MigrationJob,
ProcessorType,
} from "@autumn/shared";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { billingActions } from "@/internal/billing/v2/actions/index.js";
import { CusService } from "@/internal/customers/CusService.js";
@@ -34,11 +35,19 @@ export const migrateCustomer = async ({
customerId,
});
const customerCtx: AutumnContext = { ...ctx, logger: customerLogger };
const customerCtx: AutumnContext = {
...ctx,
customerId,
logger: customerLogger,
};
const { ctx: routedCustomerCtx } = getCtxWithCustomerRedis({
ctx: customerCtx,
customerId,
});
try {
const fullCus = await CusService.getFull({
ctx: customerCtx,
ctx: routedCustomerCtx,
idOrInternalId: customerId,
withEntities: true,
inStatuses: ACTIVE_STATUSES,
@@ -58,7 +67,7 @@ export const migrateCustomer = async ({
const cusProduct = filteredCusProducts[i];
if (cusProduct.processor?.type === ProcessorType.RevenueCat) {
await migrateRevenueCatCustomer({
ctx: customerCtx,
ctx: routedCustomerCtx,
fullCus,
cusProduct,
toProduct,
@@ -66,7 +75,7 @@ export const migrateCustomer = async ({
});
} else {
await billingActions.migrate({
ctx: customerCtx,
ctx: routedCustomerCtx,
fullCustomer: fullCus,
currentCustomerProduct: cusProduct,
newProduct: toProduct,
@@ -95,7 +104,7 @@ export const migrateCustomer = async ({
await deleteCachedFullCustomer({
customerId: fullCus.id ?? "",
ctx,
ctx: routedCustomerCtx,
});
}

View File

@@ -469,6 +469,15 @@ export class OrgService {
await clearOrgCache({ db, orgId });
}
static async listWithRedisConfig({ db }: { db: DrizzleCli }) {
const result = await db.query.organizations.findMany({
where: isNotNull(organizations.redis_config),
columns: { id: true, redis_config: true },
});
return result;
}
static async listPreviewOrgsForDeletion({ db }: { db: DrizzleCli }) {
const PREVIEW_ORG_PATTERN = "preview|%";
// 1. Find all preview orgs with no memberships

View File

@@ -0,0 +1,154 @@
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { z } from "zod/v4";
import { getOrgRedis, removeOrgRedis } from "@/external/redis/orgRedisPool.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { encryptData } from "@/utils/encryptUtils.js";
import { OrgService } from "../OrgService.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
const REDIS_PROTOCOLS = new Set(["redis:", "rediss:"]);
export const handleUpsertRedisConfig = createRoute({
scopes: [Scopes.Organisation.Write],
body: z.object({
connectionString: z.string().min(1),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
const { connectionString: rawConnectionString } = c.req.valid("json");
const connectionString = rawConnectionString.trim();
if (!connectionString) {
throw new RecaseError({
message: "Connection string is required",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (org.redis_config) {
throw new RecaseError({
message:
"Redis config already exists. Remove it before creating a new one.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
let redisUrl: URL;
try {
redisUrl = new URL(connectionString);
} catch {
throw new RecaseError({
message: "Invalid connection string: could not parse URL",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (!REDIS_PROTOCOLS.has(redisUrl.protocol)) {
throw new RecaseError({
message: "Invalid connection string: expected redis:// or rediss://",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
const now = Date.now();
const updatedOrg = await OrgService.update({
db,
orgId: org.id,
updates: {
redis_config: {
connectionString: encryptData(connectionString),
url: redisUrl.host,
migrationPercent: 0,
previousMigrationPercent: 0,
migrationChangedAt: now,
},
},
});
if (updatedOrg) {
getOrgRedis({ org: updatedOrg });
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
logger.info(
`[handleUpsertRedisConfig] org=${org.id}: redis_config created, url=${redisUrl.host}, actor=${ctx.user?.email ?? ctx.userId ?? "unknown"}`,
);
}
return c.json({ success: true });
},
});
export const handleUpdateRedisMigration = createRoute({
scopes: [Scopes.Organisation.Write],
body: z.object({
migrationPercent: z.number().int().min(0).max(100),
}),
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
const { migrationPercent } = c.req.valid("json");
if (!org.redis_config) {
throw new RecaseError({
message: "No Redis config set on this org",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
await OrgService.update({
db,
orgId: org.id,
updates: {
redis_config: {
...org.redis_config,
previousMigrationPercent: org.redis_config.migrationPercent,
migrationPercent,
migrationChangedAt: Date.now(),
},
},
});
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
logger.info(
`[handleUpdateRedisMigration] org=${org.id}: ${org.redis_config.migrationPercent}% -> ${migrationPercent}%`,
);
return c.json({ success: true });
},
});
export const handleDeleteRedisConfig = createRoute({
scopes: [Scopes.Organisation.Write],
handler: async (c) => {
const ctx = c.get("ctx");
const { db, org, logger } = ctx;
if (org.redis_config && org.redis_config.migrationPercent > 0) {
throw new RecaseError({
message: `Cannot remove Redis config while migrationPercent is ${org.redis_config.migrationPercent}%. Set it to 0 first.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Intended for use after migrationPercent has settled at 0; in-flight
// requests may still hold the old org config for a short window.
await OrgService.update({
db,
orgId: org.id,
updates: { redis_config: null },
});
await clearOrgCache({ db, orgId: org.id, env: ctx.env, logger });
removeOrgRedis({ orgId: org.id });
logger.info(
`[handleDeleteRedisConfig] org=${org.id}: redis_config removed`,
);
return c.json({ success: true });
},
});

View File

@@ -7,6 +7,11 @@ import { handleDeleteOrg } from "./handlers/crudHandlers/handleDeleteOrg.js";
import { handleGetOrg } from "./handlers/crudHandlers/handleGetOrg.js";
import { handleGetOrgFlags } from "./handlers/handleGetOrgFlags.js";
import { handleGetUploadUrl } from "./handlers/handleGetUploadUrl.js";
import {
handleDeleteRedisConfig,
handleUpdateRedisMigration,
handleUpsertRedisConfig,
} from "./handlers/handleRedisConfig.js";
import { handleResetDefaultAccount } from "./handlers/handleResetDefaultAccount.js";
import {
handleGetRevenueCatConfig,
@@ -52,6 +57,10 @@ honoOrgRouter.post("/stripe", ...handleConnectStripe);
honoOrgRouter.get("/stripe/oauth_url", ...handleGetOAuthUrl);
honoOrgRouter.post("/reset_default_account", ...handleResetDefaultAccount);
honoOrgRouter.patch("/redis", ...handleUpsertRedisConfig);
honoOrgRouter.delete("/redis", ...handleDeleteRedisConfig);
honoOrgRouter.patch("/redis/migration", ...handleUpdateRedisMigration);
honoOrgRouter.patch("/vercel", ...handleUpsertVercelConfig);
honoOrgRouter.get("/vercel_sink", ...handleGetVercelSink);

View File

@@ -265,6 +265,12 @@ export const createOrgResponse = ({
live_pkey: org.live_pkey,
onboarded: org.onboarded ?? true,
deployed: org.deployed ?? true,
redis_config: org.redis_config
? {
host: org.redis_config.url,
migrationPercent: org.redis_config.migrationPercent,
}
: null,
};
};

View File

@@ -2,6 +2,7 @@ import { type AppEnv, AuthType, createdAtToVersion } from "@autumn/shared";
import { addAppContextToLogs } from "@/utils/logging/addContextToLogs.js";
import type { DrizzleCli } from "../db/initDrizzle.js";
import type { Logger } from "../external/logtail/logtailUtils.js";
import { getCtxWithCustomerRedis } from "../external/redis/customerRedisRouting.js";
import { resolveRedisV2 } from "../external/redis/resolveRedisV2.js";
import type { AutumnContext } from "../honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "../internal/misc/rollouts/rolloutUtils.js";
@@ -86,6 +87,5 @@ export const createWorkerContext = async ({
extraLogs: {},
rolloutSnapshot,
};
return ctx;
return getCtxWithCustomerRedis({ ctx, customerId }).ctx;
};

View File

@@ -22,12 +22,15 @@ export const extractLocalEndpoint = ({
}
};
const getSqsClientConfig = () => {
const queueUrl = process.env.SQS_QUEUE_URL_V2;
const endpoint = extractLocalEndpoint({ queueUrl });
const getSqsClientConfig = ({ queueUrl }: { queueUrl?: string } = {}) => {
const resolvedQueueUrl = queueUrl ?? process.env.SQS_QUEUE_URL_V2;
const endpoint = extractLocalEndpoint({ queueUrl: resolvedQueueUrl });
const region =
extractRegionFromQueueUrl({ queueUrl: resolvedQueueUrl }) ||
DEFAULT_AWS_REGION;
return {
region:
extractRegionFromQueueUrl({ queueUrl }) || DEFAULT_AWS_REGION,
region,
...(endpoint ? { endpoint } : {}),
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
@@ -36,19 +39,57 @@ const getSqsClientConfig = () => {
};
};
const getSqsClientCacheKey = ({ queueUrl }: { queueUrl?: string } = {}) => {
const resolvedQueueUrl = queueUrl ?? process.env.SQS_QUEUE_URL_V2;
const endpoint = extractLocalEndpoint({ queueUrl: resolvedQueueUrl });
const region =
extractRegionFromQueueUrl({ queueUrl: resolvedQueueUrl }) ||
DEFAULT_AWS_REGION;
return `${region}:${endpoint ?? "aws"}`;
};
const sqsClientsByCacheKey = new Map<string, SQSClient>();
let sqsClient = new SQSClient(getSqsClientConfig());
sqsClientsByCacheKey.set(getSqsClientCacheKey(), sqsClient);
export const sqs = sqsClient;
/** Recreates the SQS client with fresh connections */
export const recreateSqsClient = (): SQSClient => {
export const recreateSqsClient = ({
queueUrl,
}: {
queueUrl?: string;
} = {}): SQSClient => {
console.log(`[SQS] Recreating SQS client (stale connection suspected)`);
sqsClient.destroy();
sqsClient = new SQSClient(getSqsClientConfig());
return sqsClient;
const cacheKey = getSqsClientCacheKey({ queueUrl });
const existingClient = sqsClientsByCacheKey.get(cacheKey);
existingClient?.destroy();
const nextClient = new SQSClient(getSqsClientConfig({ queueUrl }));
sqsClientsByCacheKey.set(cacheKey, nextClient);
if (!queueUrl || queueUrl === process.env.SQS_QUEUE_URL_V2) {
sqsClient = nextClient;
}
return nextClient;
};
/** Get the current SQS client (use this instead of direct sqs export for refreshable access) */
export const getSqsClient = (): SQSClient => sqsClient;
export const getSqsClient = ({
queueUrl,
}: {
queueUrl?: string;
} = {}): SQSClient => {
const cacheKey = getSqsClientCacheKey({ queueUrl });
const existingClient = sqsClientsByCacheKey.get(cacheKey);
if (existingClient) return existingClient;
const nextClient = new SQSClient(getSqsClientConfig({ queueUrl }));
sqsClientsByCacheKey.set(cacheKey, nextClient);
return nextClient;
};
export const QUEUE_URL = process.env.SQS_QUEUE_URL_V2 || "";

View File

@@ -471,8 +471,8 @@ export const initWorkers = async ({
db,
queueUrl,
isFifo: queueUrl.endsWith(".fifo"),
getSqsClientFn: getSqsClient,
recreateSqsClientFn: recreateSqsClient,
getSqsClientFn: () => getSqsClient({ queueUrl }),
recreateSqsClientFn: () => recreateSqsClient({ queueUrl }),
shouldPoll: () =>
isJobQueueEnabled({ queue: queueId }) &&
isActiveSlot({ serviceName: "workers" }),

View File

@@ -124,7 +124,7 @@ export const addTaskToQueue = async <T extends keyof Payloads>({
const resolvedQueueUrl = queueUrl || process.env.SQS_QUEUE_URL_V2;
if (resolvedQueueUrl) {
const sqsClient = getSqsClient();
const sqsClient = getSqsClient({ queueUrl: resolvedQueueUrl });
// SQS implementation
const isFifoQueue = resolvedQueueUrl.endsWith(".fifo");

View File

@@ -9,6 +9,7 @@ import { criticalDbMiddleware } from "../honoMiddlewares/criticalDbMiddleware.js
import { customerBlockMiddleware } from "../honoMiddlewares/customerBlockMiddleware.js";
import { idempotencyMiddleware } from "../honoMiddlewares/idempotencyMiddleware.js";
import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware.js";
import { orgRedisMiddleware } from "../honoMiddlewares/orgRedisMiddleware.js";
import { queryMiddleware } from "../honoMiddlewares/queryMiddleware.js";
import { rateLimitMiddleware } from "../honoMiddlewares/rateLimitMiddleware.js";
import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware.js";
@@ -48,6 +49,7 @@ apiRouter.use("*", secretKeyMiddleware);
apiRouter.use("*", requestBlockMiddleware);
apiRouter.use("*", orgConfigMiddleware);
apiRouter.use("*", rolloutMiddleware);
apiRouter.use("*", orgRedisMiddleware);
apiRouter.use("*", apiVersionMiddleware);
apiRouter.use("*", traceEnrichMiddleware);
apiRouter.use("*", refreshCacheMiddleware);

View File

@@ -8,6 +8,7 @@ import {
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { Logger } from "@/external/logtail/logtailUtils.js";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { computeRolloutSnapshot } from "@/internal/misc/rollouts/rolloutUtils.js";
@@ -50,7 +51,7 @@ export const createWorkerAutumnContext = async ({
const rolloutSnapshot = computeRolloutSnapshot({ orgId: org.id });
return {
const ctx = {
org,
env,
features,
@@ -71,4 +72,5 @@ export const createWorkerAutumnContext = async ({
extraLogs: {},
rolloutSnapshot,
} satisfies AutumnContext;
return getCtxWithCustomerRedis({ ctx }).ctx;
};

View File

@@ -100,7 +100,35 @@ if (cluster.isPrimary) {
startMemoryMonitor("worker", 60_000);
await startAllEdgeConfigPolling({ logger });
process.once("exit", stopAllEdgeConfigPolling);
const { db } = await import("./db/initDrizzle.js");
const { primeRedisMonitor } = await import(
"./external/redis/initUtils/redisAvailability.js"
);
const {
primeRedisV2Monitor,
startRedisV2Monitor,
stopRedisV2Monitor,
} = await import("./external/redis/initUtils/redisV2Availability.js");
const { startRedisMonitor, stopRedisMonitor } = await import(
"./external/redis/initRedis.js"
);
const { preWarmOrgRedisConnections } = await import(
"./external/redis/orgRedisPool.js"
);
await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]);
startRedisMonitor();
startRedisV2Monitor();
void preWarmOrgRedisConnections({ db }).catch((error) => {
logger.warn("[OrgRedis] Warmup failed", { error });
});
process.once("exit", () => {
stopAllEdgeConfigPolling();
stopRedisMonitor();
stopRedisV2Monitor();
});
const { initWorkers } = await import("./queue/initWorkers.js");
await initWorkers({ startupStartedAt, queueImplementation });

View File

@@ -9,5 +9,7 @@ export const temp: TestGroup = {
// "integration/billing/stripe-webhooks/subscription-created",
"integration/billing/sync/to-sync-params",
// "integration/billing/restore",
"integration/balances/track/basic/track-credit-system-all-balances",
"integration/balances/check/check-send-event-credit-system",
],
};

View File

@@ -2,7 +2,9 @@ import type { Customer } from "@autumn/shared";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import { clearCusEntsFromCache } from "@/cron/resetCron/clearCusEntsFromCache";
import { resetCustomerEntitlement } from "@/cron/resetCron/resetCustomerEntitlement.js";
import { invalidateCustomerEntitlementBalance } from "@/internal/customers/cache/fullSubject/actions/invalidate/invalidateCustomerEntitlementBalance";
import { getCtxWithCustomerRedis } from "@/external/redis/customerRedisRouting.js";
import { waitForRedisReady } from "@/external/redis/initRedis.js";
import { invalidateCachedFullSubject } from "@/internal/customers/cache/fullSubject/index.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { cusProductToCusEnt } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
@@ -40,21 +42,27 @@ export const resetAndGetCusEnt = async ({
customer,
};
const updatedCusEnt = await resetCustomerEntitlement({
const { ctx: routedCtx } = getCtxWithCustomerRedis({
ctx,
customerId: customer.id ?? "",
});
await waitForRedisReady(routedCtx.redisV2, "customer-redis", 5000).catch(
() => undefined,
);
const updatedCusEnt = await resetCustomerEntitlement({
ctx: routedCtx,
org: ctx.org,
cusEnt: resetCusEnt,
updatedCusEnts: [],
persistFreeOverage,
});
if (!skipCacheDeletion) {
await invalidateCustomerEntitlementBalance({
orgId: customer.org_id,
env: customer.env,
await invalidateCachedFullSubject({
ctx: routedCtx,
customerId: customer.id ?? "",
featureId,
customerEntitlementId: resetCusEnt.id,
redisV2: ctx.redisV2,
source: "resetAndGetCusEnt",
});
await clearCusEntsFromCache({

View File

@@ -0,0 +1,81 @@
import { expect, test } from "bun:test";
import type { CheckResponseV3 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// Check + send_event on a feature linked to a credit system should:
// - keep `balance` populated with the tracked feature (backwards compat)
// - populate `balances` with both the feature and the credit system
test.concurrent(`${chalk.yellowBright("check-send-event-credit-system: returns balance + balances for linked credit system")}`, async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 200,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "check-send-event-credit-system",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const checkRes = await autumnV2_2.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Action1,
required_balance: 5,
send_event: true,
});
expect(checkRes.allowed).toBe(true);
// Backwards-compat single balance
expect(checkRes.balance).not.toBeNull();
expect(checkRes.balance?.feature_id).toBe(TestFeature.Action1);
// New balances field exposes both the feature and its credit system
expect(checkRes.balances).toBeDefined();
expect(Object.keys(checkRes.balances ?? {}).sort()).toEqual(
[TestFeature.Action1, TestFeature.Credits].sort(),
);
expect(checkRes.balances?.[TestFeature.Action1]).not.toBeNull();
expect(checkRes.balances?.[TestFeature.Credits]).not.toBeNull();
});
test.concurrent(`${chalk.yellowBright("check-send-event-no-credit-system: returns single balance and no balances field")}`, async () => {
const messagesItem = items.free({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "check-send-event-no-credit-system",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const checkRes = await autumnV2_2.check<CheckResponseV3>({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 1,
send_event: true,
});
expect(checkRes.allowed).toBe(true);
expect(checkRes.balance).not.toBeNull();
expect(checkRes.balance?.feature_id).toBe(TestFeature.Messages);
expect(checkRes.balances).toBeUndefined();
});

View File

@@ -201,10 +201,13 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-4: no expires_at sets T
lock: { enabled: true, lock_id: customerId },
});
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const fetchedReceipt = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance =
fetchedReceipt.source === "redis_v2" ? fetchedReceipt.redisInstance : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expireAt = await redisInstance.expiretime(
fetchedReceipt.lockReceiptKey,
);
const expectedTtl = beforeCheck + 24 * 60 * 60;
// TTL should be within 5s of now + 1 day
@@ -238,10 +241,13 @@ test.concurrent(`${chalk.yellowBright("check-lock-expiry-5: expires_at set, TTL
lock: { enabled: true, lock_id: customerId, expires_at: expiresAt },
});
const { lockReceiptKey, source } = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance = source === "redis_v2" ? ctx.redisV2 : redis;
const fetchedReceipt = await fetchLockReceipt({ ctx, lockId: customerId });
const redisInstance =
fetchedReceipt.source === "redis_v2" ? fetchedReceipt.redisInstance : redis;
const expireAt = await redisInstance.expiretime(lockReceiptKey);
const expireAt = await redisInstance.expiretime(
fetchedReceipt.lockReceiptKey,
);
const expectedTtl = Math.ceil(expiresAt / 1000) + 60 * 60;
// TTL should be within 5s of expires_at + 1 hour

View File

@@ -3,6 +3,7 @@ import type { ApiCustomerV5 } from "@autumn/shared";
import { expectCustomerEventsCorrect } from "@tests/integration/balances/utils/events/expectCustomerEventsCorrect.js";
import { deleteLock } from "@tests/integration/balances/utils/lockUtils/deleteLock.js";
import { expectLockReceiptDeleted } from "@tests/integration/balances/utils/lockUtils/expectLockReceiptDeleted.js";
import { warmEntityCaches } from "@tests/integration/balances/utils/warmEntityCaches";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
@@ -63,6 +64,7 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-2: entity-level lock=30 co
});
await deleteLock({ ctx, lockId: lockKey });
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
// Lock at entity level (ent-1)
await autumnV2_1.check({
@@ -139,6 +141,7 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-3: entity-level lock=30 co
});
await deleteLock({ ctx, lockId: lockKey });
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
await autumnV2_1.check({
customer_id: customerId,
@@ -310,6 +313,7 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-5: entity-level across loc
});
await deleteLock({ ctx, lockId: lockKey });
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
// Customer-level lock (no entity_id)
await autumnV2_1.check({
@@ -392,6 +396,7 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-6: customer-level lock=120
});
await deleteLock({ ctx, lockId: lockKey });
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
await autumnV2_1.check({
customer_id: customerId,
@@ -477,6 +482,7 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-7: ent-1 lock held while c
});
await deleteLock({ ctx, lockId: lockKey });
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
// Lock ent-1 (deducts 30 from ent-1 bucket)
await autumnV2_1.check({
@@ -568,6 +574,8 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-8: two concurrent entity l
deleteLock({ ctx, lockId: lockKeyB }),
]);
await warmEntityCaches({ autumn: autumnV2_1, customerId, entities });
// Fire both locks concurrently
await Promise.all([
autumnV2_1.check({
@@ -600,15 +608,6 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-8: two concurrent entity l
}),
]);
// Lock A confirm: delta=15-30=-15 → restore 15 to ent-1 (→35)
// Lock B confirm: delta=25-20=+5 → deduct 5 more from ent-2 (→25)
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 160,
});
const ent1 = await autumnV2_1.entities.get<ApiCustomerV5>(
customerId,
entities[0].id,
@@ -619,6 +618,15 @@ test.concurrent(`${chalk.yellowBright("lock-entity EP-8: two concurrent entity l
remaining: 135,
});
// Lock A confirm: delta=15-30=-15 → restore 15 to ent-1 (→35)
// Lock B confirm: delta=25-20=+5 → deduct 5 more from ent-2 (→25)
const customer = await autumnV2_1.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 160,
});
const ent2 = await autumnV2_1.entities.get<ApiCustomerV5>(
customerId,
entities[1].id,

View File

@@ -3,7 +3,6 @@ import {
type ApiCustomer,
type ApiCustomerV3,
customerEntitlements,
type OrgConfig,
} from "@autumn/shared";
import { resetAndGetCusEnt } from "@tests/balances/track/rollovers/rolloverTestUtils.js";
import { findCustomerEntitlement } from "@tests/balances/utils/findCustomerEntitlement.js";
@@ -20,7 +19,6 @@ import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { db } from "@/db/initDrizzle.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
const setBalanceInDb = async ({
cusEntId,
@@ -35,33 +33,14 @@ const setBalanceInDb = async ({
.where(eq(customerEntitlements.id, cusEntId));
};
const enablePersistFreeOverage = async ({
orgId,
orgConfig,
}: {
orgId: string;
orgConfig: OrgConfig;
}) => {
await OrgService.update({
db,
orgId,
updates: { config: { ...orgConfig, persist_free_overage: true } },
const persistFreeOverageOrg = ({ slug }: { slug: string }) =>
s.platform.create({
userEmail: `persist-overage-${slug}-${Math.random()
.toString(36)
.slice(2, 8)}@autumn.test`,
configOverrides: { persist_free_overage: true },
setupDefaultFeatures: true,
});
};
const disablePersistFreeOverage = async ({
orgId,
orgConfig,
}: {
orgId: string;
orgConfig: OrgConfig;
}) => {
await OrgService.update({
db,
orgId,
updates: { config: { ...orgConfig, persist_free_overage: false } },
});
};
// ─────────────────────────────────────────────────────────────────
// 1. Lazy reset (DB path)
@@ -73,41 +52,33 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (DB): lazy reset deduc
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-db",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
setup: [
persistFreeOverageOrg({ slug: "db" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -100 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -100 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(0);
expect(after.balances[TestFeature.Messages].usage).toBe(100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(0);
expect(after.balances[TestFeature.Messages].usage).toBe(100);
});
// ─────────────────────────────────────────────────────────────────
@@ -120,59 +91,51 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (cache): lazy reset de
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-cache",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
setup: [
persistFreeOverageOrg({ slug: "cache" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
await autumnV2.customers.get<ApiCustomer>(customerId);
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -50 });
await setCachedCusEntField({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
env: ctx.env,
customerId,
cusEntId: cusEnt!.id,
field: "balance",
value: -50,
});
await setCachedSubjectBalanceField({
ctx,
orgId: ctx.org.id,
env: ctx.env,
customerId,
featureId: TestFeature.Messages,
customerEntitlementId: cusEnt!.id,
field: "balance",
value: -50,
});
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
try {
await autumnV2.customers.get<ApiCustomer>(customerId);
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: -50 });
await setCachedCusEntField({
orgId: ctx.org.id,
env: ctx.env,
customerId,
cusEntId: cusEnt!.id,
field: "balance",
value: -50,
});
await setCachedSubjectBalanceField({
orgId: ctx.org.id,
env: ctx.env,
customerId,
featureId: TestFeature.Messages,
customerEntitlementId: cusEnt!.id,
field: "balance",
value: -50,
redisV2: ctx.redisV2,
});
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(after.balances[TestFeature.Messages].current_balance).toBe(50);
expect(after.balances[TestFeature.Messages].usage).toBe(50);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
const after = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(after.balances[TestFeature.Messages].current_balance).toBe(50);
expect(after.balances[TestFeature.Messages].usage).toBe(50);
});
// ─────────────────────────────────────────────────────────────────
@@ -185,7 +148,11 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (cron): cron reset ded
const { customerId, ctx, customer } = await initScenario({
customerId: "persist-ovg-on-cron",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
setup: [
persistFreeOverageOrg({ slug: "cron" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
@@ -219,41 +186,33 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (no overage): positive
const { customerId, autumnV2, ctx } = await initScenario({
customerId: "persist-ovg-on-positive",
setup: [s.customer({ testClock: false }), s.products({ list: [free] })],
setup: [
persistFreeOverageOrg({ slug: "positive" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
],
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: 50 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
await setBalanceInDb({ cusEntId: cusEnt!.id, balance: 50 });
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(100);
expect(after.balances[TestFeature.Messages].usage).toBe(0);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
const after = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(after.balances[TestFeature.Messages].current_balance).toBe(100);
expect(after.balances[TestFeature.Messages].usage).toBe(0);
});
// ─────────────────────────────────────────────────────────────────
@@ -270,6 +229,7 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity): each ent
const { customerId, ctx } = await initScenario({
customerId: "persist-ovg-on-entity",
setup: [
persistFreeOverageOrg({ slug: "entity" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
@@ -277,56 +237,43 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity): each ent
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
ctx.org.config = { ...ctx.org.config, persist_free_overage: true };
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
entities[entityIds[0]].balance = -50;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = 30;
entities[entityIds[1]].adjustment = 0;
entities[entityIds[0]].balance = -50;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = 30;
entities[entityIds[1]].adjustment = 0;
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(50);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(50);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(100);
});
// ─────────────────────────────────────────────────────────────────
@@ -343,6 +290,7 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity mixed): di
const { customerId, ctx } = await initScenario({
customerId: "persist-ovg-on-ent-mix",
setup: [
persistFreeOverageOrg({ slug: "entity-mixed" }),
s.customer({ testClock: false }),
s.products({ list: [free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
@@ -350,56 +298,43 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (per-entity mixed): di
actions: [s.attach({ productId: free.id })],
});
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
ctx.org.config = { ...ctx.org.config, persist_free_overage: true };
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
try {
const cusEnt = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEnt).toBeDefined();
expect(cusEnt!.entities).toBeDefined();
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
const entities = { ...cusEnt!.entities! };
const entityIds = Object.keys(entities);
expect(entityIds.length).toBe(2);
entities[entityIds[0]].balance = -30;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = -200;
entities[entityIds[1]].adjustment = 0;
entities[entityIds[0]].balance = -30;
entities[entityIds[0]].adjustment = 0;
entities[entityIds[1]].balance = -200;
entities[entityIds[1]].adjustment = 0;
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
await db
.update(customerEntitlements)
.set({ entities })
.where(eq(customerEntitlements.id, cusEnt!.id));
await expireCusEntForReset({
ctx,
customerId,
featureId: TestFeature.Messages,
});
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
const cusEntAfter = await findCustomerEntitlement({
ctx,
customerId,
featureId: TestFeature.Messages,
});
expect(cusEntAfter).toBeDefined();
expect(cusEntAfter!.entities).toBeDefined();
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(70);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(-100);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
expect(cusEntAfter!.entities![entityIds[0]].balance).toBe(70);
expect(cusEntAfter!.entities![entityIds[1]].balance).toBe(-100);
});
// ─────────────────────────────────────────────────────────────────
@@ -418,6 +353,7 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (prepaid): invoice res
const { customerId, autumnV1, ctx, testClockId } = await initScenario({
customerId: "persist-ovg-on-prepaid",
setup: [
persistFreeOverageOrg({ slug: "prepaid" }),
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
@@ -430,38 +366,24 @@ test.concurrent(`${chalk.yellowBright("persist overage ON (prepaid): invoice res
],
});
// Enable flag BEFORE advancing, so the webhook handler sees it in DB
await enablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
// After attach: prepaid balance = 200 (quantity=200, billingUnits=100, so 2*100=200), lifetime = 50
// Total messages balance = 200 + 50 = 250
// After track 300: deducted 300 from messages
// The prepaid cusEnt's balance should be negative (overage)
// On advance, handlePrepaidPrices resets prepaid with persistFreeOverage
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
try {
// After attach: prepaid balance = 200 (quantity=200, billingUnits=100, so 2*100=200), lifetime = 50
// Total messages balance = 200 + 50 = 250
// After track 300: deducted 300 from messages
// The prepaid cusEnt's balance should be negative (overage)
// On advance, handlePrepaidPrices resets prepaid with persistFreeOverage
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Lifetime messages (50) should be untouched by the prepaid reset
// Prepaid: was 200, used 300 -> overage of some amount on the prepaid cusEnt
// After reset with persist_free_overage: new prepaid balance = 200 - overage
// The exact split depends on deduction order, so just verify the feature exists
// and the balance is less than the full 250 (200 prepaid + 50 lifetime)
expect(customer.features[TestFeature.Messages]).toBeDefined();
expect(customer.features[TestFeature.Messages].balance).toBeLessThan(250);
} finally {
await disablePersistFreeOverage({
orgId: ctx.org.id,
orgConfig: ctx.org.config,
});
}
// Lifetime messages (50) should be untouched by the prepaid reset
// Prepaid: was 200, used 300 -> overage of some amount on the prepaid cusEnt
// After reset with persist_free_overage: new prepaid balance = 200 - overage
// The exact split depends on deduction order, so just verify the feature exists
// and the balance is less than the full 250 (200 prepaid + 50 lifetime)
expect(customer.features[TestFeature.Messages]).toBeDefined();
expect(customer.features[TestFeature.Messages].balance).toBeLessThan(250);
});

View File

@@ -0,0 +1,223 @@
import { expect, test } from "bun:test";
import type { TrackResponseV3 } from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Scenario A: track feature_id linked to 2 credit systems
// Action1 → Credits, and we add an extra credit system entry that
// references Action1 too. v2Features only ships Credits referencing
// Action1; we use Action1 + Credits (1 main + 1 credit system) = 2
// balances. To get 3 balances we'd need an extra credit system —
// instead we cover the multi-credit-system case via Scenario B.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-all-balances-A: track feature_id linked to credit system returns both balances")}`, async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 200,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-all-balances-a",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 10,
});
// `balance` keeps backwards-compat single-feature heuristic
expect(trackRes.balance).not.toBeNull();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Action1);
// `balances` exposes the feature plus its linked credit system
expect(trackRes.balances).toBeDefined();
expect(Object.keys(trackRes.balances ?? {}).sort()).toEqual(
[TestFeature.Action1, TestFeature.Credits].sort(),
);
expect(trackRes.balances?.[TestFeature.Action1]).not.toBeNull();
expect(trackRes.balances?.[TestFeature.Credits]).not.toBeNull();
});
// ═══════════════════════════════════════════════════════════════════
// Scenario B: event_name → 2 features, each with 1 credit system
// "action-event" matches Action1 (→ Credits) and Action3 (→ Credits2).
// Expect 4 balances: Action1, Action3, Credits, Credits2.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-all-balances-B: event_name across two features returns four balances")}`, async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 80,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 150,
});
const action3Item = items.free({
featureId: TestFeature.Action3,
includedUsage: 60,
});
const credits2Item = items.free({
featureId: TestFeature.Credits2,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem, action3Item, credits2Item],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-all-balances-b",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
event_name: "action-event",
value: 5,
});
expect(trackRes.balance).toBeNull();
expect(trackRes.balances).toBeDefined();
expect(Object.keys(trackRes.balances ?? {}).sort()).toEqual(
[
TestFeature.Action1,
TestFeature.Action3,
TestFeature.Credits,
TestFeature.Credits2,
].sort(),
);
for (const fid of [
TestFeature.Action1,
TestFeature.Action3,
TestFeature.Credits,
TestFeature.Credits2,
]) {
expect(trackRes.balances?.[fid]).not.toBeNull();
}
});
// ═══════════════════════════════════════════════════════════════════
// Scenario C: feature_id with no linked credit systems
// Messages has no credit system referencing it. Expect single balance.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-all-balances-C: feature_id with no credit systems returns single balance")}`, async () => {
const messagesItem = items.free({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [messagesItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-all-balances-c",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 7,
});
expect(trackRes.balance).not.toBeNull();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Messages);
expect(trackRes.balances).toBeUndefined();
});
// ═══════════════════════════════════════════════════════════════════
// Scenario D: tracking a credit system directly does NOT return its
// metered features (no walking backwards).
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-all-balances-D: tracking credit system directly returns only that balance")}`, async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 200,
});
const freeProd = products.base({
id: "free",
items: [action1Item, creditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-all-balances-d",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
value: 5,
});
expect(trackRes.balance).not.toBeNull();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Credits);
expect(trackRes.balances).toBeUndefined();
});
// ═══════════════════════════════════════════════════════════════════
// Scenario E: relevant credit system feature exists in the org but
// the customer has no entitlement to it. Response key is present
// with value null.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("track-all-balances-E: missing entitlement on related feature returns null entry")}`, async () => {
const action1Item = items.free({
featureId: TestFeature.Action1,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [action1Item],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-all-balances-e",
setup: [s.customer({ testClock: false }), s.products({ list: [freeProd] })],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 5,
});
// `balance` falls back to Action1 (Credits is not entitled)
expect(trackRes.balance).not.toBeNull();
expect(trackRes.balance?.feature_id).toBe(TestFeature.Action1);
// `balances` includes the null entry for the missing entitlement
expect(trackRes.balances).toBeDefined();
expect(Object.keys(trackRes.balances ?? {}).sort()).toEqual(
[TestFeature.Action1, TestFeature.Credits].sort(),
);
expect(trackRes.balances?.[TestFeature.Action1]).not.toBeNull();
expect(trackRes.balances?.[TestFeature.Credits]).toBeNull();
});

View File

@@ -190,6 +190,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde
current_balance: 100 - deduct1,
usage: deduct1,
});
expect(trackRes1.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes1.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer1 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
@@ -224,6 +226,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde
current_balance: 0,
usage: 100,
});
expect(trackRes2.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes2.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
@@ -256,6 +260,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde
feature_id: TestFeature.Credits,
current_balance: new Decimal(creditsBefore!).minus(creditCost3).toNumber(),
});
expect(trackRes3.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes3.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer3 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
@@ -337,8 +343,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with
expect(trackRes1.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes1.balances?.[TestFeature.Action3]).toBeDefined();
expect(trackRes1.balances?.[TestFeature.Credits]).toBeUndefined();
expect(trackRes1.balances?.[TestFeature.Credits2]).toBeUndefined();
expect(trackRes1.balances?.[TestFeature.Credits]).toBeDefined();
expect(trackRes1.balances?.[TestFeature.Credits2]).toBeDefined();
const customer1 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer1.features[TestFeature.Action1]).toMatchObject({
@@ -381,8 +387,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with
expect(trackRes2.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes2.balances?.[TestFeature.Action3]).toBeDefined();
expect(trackRes2.balances?.[TestFeature.Credits]).toBeUndefined();
expect(trackRes2.balances?.[TestFeature.Credits2]).toBeUndefined();
expect(trackRes2.balances?.[TestFeature.Credits]).toBeDefined();
expect(trackRes2.balances?.[TestFeature.Credits2]).toBeDefined();
const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer2.features[TestFeature.Action1]).toMatchObject({
@@ -431,8 +437,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with
value: deduct3,
});
expect(trackRes3.balances?.[TestFeature.Action1]).toBeUndefined();
expect(trackRes3.balances?.[TestFeature.Action3]).toBeUndefined();
expect(trackRes3.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes3.balances?.[TestFeature.Action3]).toBeDefined();
expect(trackRes3.balances?.[TestFeature.Credits]).toBeDefined();
expect(trackRes3.balances?.[TestFeature.Credits2]).toBeDefined();
@@ -531,6 +537,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde
current_balance: 100 - deduct1,
usage: deduct1,
});
expect(trackRes1.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes1.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer1 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
@@ -570,6 +578,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde
current_balance: 0,
usage: 100,
});
expect(trackRes2.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes2.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
@@ -607,6 +617,8 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde
feature_id: TestFeature.Credits,
current_balance: new Decimal(creditsBefore!).minus(creditCost3).toNumber(),
});
expect(trackRes3.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes3.balances?.[TestFeature.Credits]).toBeDefined();
await timeout(2000);
const customer3 = await autumnV1.customers.get<ApiCustomerV3>(customerId, {

View File

@@ -49,6 +49,8 @@ test.concurrent(`${chalk.yellowBright("track-event-name1: track with event_name
expect(trackRes.balance?.feature_id).toBe(TestFeature.Action1);
expect(trackRes.balance?.current_balance).toBe(expectedBalance);
expect(trackRes.balance?.usage).toBe(deductValue);
expect(trackRes.balances?.[TestFeature.Action1]).toBeDefined();
expect(trackRes.balances?.[TestFeature.Credits]).toBeNull();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.Action1]).toMatchObject({

View File

@@ -1,5 +1,6 @@
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2OrgCleanupCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
import { buildClaimMarkerKey } from "@/internal/balances/utils/lockV2/buildClaimMarkerKey.js";
@@ -20,6 +21,8 @@ export const deleteLock = async ({
await Promise.all([
redis.del(redisReceiptKey),
ctx.redisV2.del(redisReceiptKey, claimMarkerKey),
...getRedisV2OrgCleanupCandidates({ ctx }).map((redisInstance) =>
redisInstance.del(redisReceiptKey, claimMarkerKey),
),
]);
};

View File

@@ -1,6 +1,7 @@
import { expect } from "bun:test";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext.js";
import { redis } from "@/external/redis/initRedis.js";
import { getRedisV2OrgCleanupCandidates } from "@/external/redis/orgRedisUtils/orgRedisMigrationUtils.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
/** Asserts that the lock receipt for the given ID no longer exists in Redis. */
@@ -20,4 +21,13 @@ export const expectLockReceiptDeleted = async ({
const receipt = await redis.call("JSON.GET", redisReceiptKey, "$");
expect(receipt).toBeNull();
const v2ReceiptCounts = await Promise.all(
getRedisV2OrgCleanupCandidates({ ctx }).map((redisInstance) =>
redisInstance.exists(redisReceiptKey),
),
);
for (const receiptCount of v2ReceiptCounts) {
expect(receiptCount).toBe(0);
}
};

View File

@@ -0,0 +1,192 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type AttachParamsV1Input,
CusProductStatus,
ErrCode,
} from "@autumn/shared";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, subDays } from "date-fns";
import type Stripe from "stripe";
import { getCustomerProduct } from "./utils";
test.concurrent(`${chalk.yellowBright("ends-at: immediate attach sets subscription cancel_at")}`, async () => {
const customerId = "attach-ends-at-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const endsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
ends_at: endsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.ended_at).toBe(endsAt);
expect(cusProduct.subscription_ids).toHaveLength(1);
const stripeSubscription = await ctx.stripeCli.subscriptions.retrieve(
cusProduct.subscription_ids![0]!,
);
expect(stripeSubscription.cancel_at).toBe(Math.floor(endsAt / 1000));
});
test.concurrent(`${chalk.yellowBright("ends-at: future attach creates bounded schedule phase")}`, async () => {
const customerId = "attach-starts-ends-at-future";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startsAt = addDays(advancedTo, 1).getTime();
const endsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startsAt,
ends_at: endsAt,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.starts_at).toBe(startsAt);
expect(cusProduct.ended_at).toBe(endsAt);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startsAt / 1000),
);
expect(stripeSchedule.phases[0]?.end_date).toBe(Math.floor(endsAt / 1000));
expect(stripeSchedule.end_behavior).toBe("cancel");
});
test.concurrent(
`${chalk.yellowBright("ends-at: past dates are rejected")}`,
async () => {
const customerId = "attach-end-date-past";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "ends_at cannot be set to a past timestamp",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
ends_at: subDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("ends-at: must be after starts_at")}`,
async () => {
const customerId = "attach-end-date-before-start";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "ends_at must be after the plan start timestamp",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 7).getTime(),
ends_at: addDays(advancedTo, 1).getTime(),
}),
});
},
);
test.concurrent(
`${chalk.yellowBright("ends-at: rejects free plans")}`,
async () => {
const customerId = "attach-end-date-free";
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [free] })],
actions: [],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "ends_at is only supported for paid recurring plans",
func: () =>
autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: free.id,
ends_at: addDays(advancedTo, 7).getTime(),
}),
});
},
);

View File

@@ -0,0 +1,206 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type AttachParamsV1Input,
CusProductStatus,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays } from "date-fns";
import type Stripe from "stripe";
import { expectResetAnchoredTo, getCustomerProduct } from "./utils";
test.concurrent(`${chalk.yellowBright("starts_at: enable_plan_immediately activates access before billing")}`, async () => {
const customerId = "attach-start-date-enable-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
enable_plan_immediately: true,
});
expect(preview.total).toBe(0);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
enable_plan_immediately: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expect(cusProduct.starts_at).toBe(startDate);
expect(Math.abs(cusProduct.access_starts_at! - advancedTo)).toBeLessThan(
ms.minutes(10),
);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: upgrade access can start before billing switch")}`, async () => {
const customerId = "attach-starts-at-upgrade-enable-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const startsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
starts_at: startsAt,
enable_plan_immediately: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
const premiumCustomerProduct = await getCustomerProduct({
ctx,
customerId,
productId: premium.id,
});
expect(premiumCustomerProduct.status).toBe(CusProductStatus.Active);
expect(premiumCustomerProduct.scheduled_ids).toHaveLength(1);
expect(premiumCustomerProduct.starts_at).toBe(startsAt);
expect(
Math.abs(premiumCustomerProduct.access_starts_at! - advancedTo),
).toBeLessThan(ms.minutes(10));
expectResetAnchoredTo({
cusProduct: premiumCustomerProduct,
featureId: TestFeature.Messages,
startDate: startsAt,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
premiumCustomerProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
const futurePhase = stripeSchedule.phases.find(
(phase) => phase.start_date === Math.floor(startsAt / 1000),
);
expect(futurePhase).toBeDefined();
});
test.concurrent(`${chalk.yellowBright("starts_at: add-on access can start before billing")}`, async () => {
const customerId = "attach-starts-at-addon-enable-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyWords({ includedUsage: 5 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const startsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: startsAt,
enable_plan_immediately: true,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductActive({ customer, productId: addon.id });
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
const addonCustomerProduct = await getCustomerProduct({
ctx,
customerId,
productId: addon.id,
});
expect(addonCustomerProduct.status).toBe(CusProductStatus.Active);
expect(addonCustomerProduct.scheduled_ids).toHaveLength(1);
expect(addonCustomerProduct.starts_at).toBe(startsAt);
expect(
Math.abs(addonCustomerProduct.access_starts_at! - advancedTo),
).toBeLessThan(ms.minutes(10));
expectResetAnchoredTo({
cusProduct: addonCustomerProduct,
featureId: TestFeature.Words,
startDate: startsAt,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
addonCustomerProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
const futurePhase = stripeSchedule.phases.find(
(phase) => phase.start_date === Math.floor(startsAt / 1000),
);
expect(futurePhase).toBeDefined();
expect(futurePhase!.items.length).toBeGreaterThan(
stripeSchedule.phases[0]?.items.length ?? 0,
);
});

View File

@@ -0,0 +1,596 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type ApiEntityV0,
type AttachParamsV0Input,
type AttachParamsV1Input,
CollectionMethod,
CusProductStatus,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} 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 { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, addHours, addMinutes, addMonths } from "date-fns";
import type Stripe from "stripe";
import { CusService } from "@/internal/customers/CusService";
import {
expectResetAnchoredTo,
getCustomerProduct,
triggerSubscriptionCreated,
} from "./utils";
const getScheduleSubscriptionId = (schedule: Stripe.SubscriptionSchedule) =>
typeof schedule.subscription === "string"
? schedule.subscription
: schedule.subscription?.id;
test.concurrent(`${chalk.yellowBright("starts_at: future attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-future";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
expect(preview.total).toBe(0);
expect(preview.next_cycle?.starts_at).toBe(startDate);
expect(preview.next_cycle?.total).toBe(20);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.access_starts_at).toBeNull();
expect(cusProduct.collection_method).toBe(
CollectionMethod.ChargeAutomatically,
);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: future attach without payment method creates invoice schedule")}`, async () => {
const customerId = "attach-start-date-future-invoice";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ email: "future-invoice@example.com" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const result = await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
expect(result.payment_url).toBeNull();
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.collection_method).toBe(CollectionMethod.SendInvoice);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.default_settings.collection_method).toBe(
"send_invoice",
);
expect(stripeSchedule.default_settings.invoice_settings.days_until_due).toBe(
30,
);
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: beta attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-beta";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV1Beta.billing.previewAttach<AttachParamsV0Input>(
{
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
},
);
expect(preview.total).toBe(0);
await autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: now attaches immediately")}`, async () => {
const customerId = "attach-start-date-now";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids?.length).toBe(1);
expect(cusProduct.scheduled_ids ?? []).toEqual([]);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate: advancedTo,
});
});
test.concurrent(`${chalk.yellowBright("starts_at: entity attach with existing scheduled switch")}`, async () => {
const customerId = "attach-starts-at-entity-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
});
const entityA = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityAId,
);
const entityB = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityBId,
);
await expectProductCanceling({ customer: entityA, productId: premium.id });
await expectProductScheduled({ customer: entityA, productId: pro.id });
await expectProductScheduled({
customer: entityB,
productId: pro.id,
startsAt,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.scheduled_ids?.length).toBeGreaterThan(0);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: entity attach creates new billing subscription beside existing schedule")}`, async () => {
const customerId = "attach-starts-at-entity-new-sub-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
new_billing_subscription: true,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.subscription_ids ?? []).toEqual([]);
expect(entityBCusProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
entityBCusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startsAt / 1000),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: immediate attach replaces scheduled plan")}`, async () => {
const customerId = "attach-starts-at-future-then-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledPro =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledPro,
productId: pro.id,
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
});
test.concurrent(`${chalk.yellowBright("starts_at: custom switch date schedules plan change")}`, async () => {
const customerId = "attach-starts-at-custom-switch";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
const startsAt = addMonths(advancedTo, 2).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id, startsAt });
});
test.concurrent(`${chalk.yellowBright("starts_at: add-on starts in the future")}`, async () => {
const customerId = "attach-starts-at-addon";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const startsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductScheduled({ customer, productId: addon.id, startsAt });
});
test.concurrent(`${chalk.yellowBright("starts_at: scheduled add-on is removed when base plan is canceled immediately")}`, async () => {
const customerId = "attach-starts-at-addon-cancel-base";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV1, autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledAddon =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledAddon,
productId: addon.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id, addon.id],
});
});
test.concurrent(`${chalk.yellowBright("starts_at: test clock start links and activates subscription")}`, async () => {
const customerId = "attach-start-date-clock";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: addHours(startDate, 1).getTime(),
waitForSeconds: 30,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
scheduleId!,
)) as Stripe.SubscriptionSchedule;
const stripeSubId = getScheduleSubscriptionId(stripeSchedule);
if (!stripeSubId) throw new Error("Expected schedule to have a subscription");
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: addMinutes(startDate, 5).getTime(),
fullCustomer: await CusService.getFull({
ctx,
idOrInternalId: customerId,
}),
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids).toEqual([stripeSubId]);
});

Some files were not shown because too many files have changed in this diff Show More