Merge branch 'dev' into feat/external-psp-signals-on-customers
This commit is contained in:
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
26
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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 -->
|
||||
@@ -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
2
ai
Submodule ai updated: e21275f18e...18d68d3c2a
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ export const runResetCron = async ({ ctx }: { ctx: CronContext }) => {
|
||||
batchResets.push(
|
||||
resetCustomerEntitlement({
|
||||
ctx,
|
||||
org: orgWithFeatures.org,
|
||||
cusEnt: cusEnt,
|
||||
updatedCusEnts,
|
||||
persistFreeOverage:
|
||||
|
||||
110
server/src/external/redis/customerRedisRouting.ts
vendored
Normal file
110
server/src/external/redis/customerRedisRouting.ts
vendored
Normal 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)];
|
||||
};
|
||||
62
server/src/external/redis/customerRedisRoutingInfo.ts
vendored
Normal file
62
server/src/external/redis/customerRedisRoutingInfo.ts
vendored
Normal 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;
|
||||
};
|
||||
102
server/src/external/redis/orgRedisPool.ts
vendored
Normal file
102
server/src/external/redis/orgRedisPool.ts
vendored
Normal 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 });
|
||||
}
|
||||
};
|
||||
37
server/src/external/redis/orgRedisUtils/orgRedisMigrationUtils.ts
vendored
Normal file
37
server/src/external/redis/orgRedisUtils/orgRedisMigrationUtils.ts
vendored
Normal 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 });
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
10
server/src/external/stripe/stripeCusUtils.ts
vendored
10
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
13
server/src/honoMiddlewares/orgRedisMiddleware.ts
Normal file
13
server/src/honoMiddlewares/orgRedisMiddleware.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
42
server/src/internal/auth/handleListAuthOrganizations.ts
Normal file
42
server/src/internal/auth/handleListAuthOrganizations.ts
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ const runFinalizeLockInner = async ({ ctx, params }: RunFinalizeLockArgs) => {
|
||||
receipt: fetchedReceipt.receipt,
|
||||
lockReceiptKey: fetchedReceipt.lockReceiptKey,
|
||||
claimed: fetchedReceipt.claimed,
|
||||
lockRedisInstance: fetchedReceipt.redisInstance,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 3–4 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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
154
server/src/internal/orgs/handlers/handleRedisConfig.ts
Normal file
154
server/src/internal/orgs/handlers/handleRedisConfig.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user