merged from main
This commit is contained in:
2
ai
2
ai
Submodule ai updated: 761b842553...008fd7a292
18
bun.lock
18
bun.lock
@@ -5902,6 +5902,8 @@
|
||||
|
||||
"@autumn/server/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260510.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260510.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260510.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-05U6/Im+vmqGrFAVrHSeuoXBCwShhbiA+93VpSwEBYP4LMWk2JW9q87MydamL5g6ISEjIVlwQ4Dx35CauPAwpA=="],
|
||||
|
||||
"@autumn/server/autumn-js": ["autumn-js@0.1.85", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-PDud/t8z5bDJcD7ptyHzTaoJ0A8zkxvQ4TYcJ48RtgKDdOkVY36D1T6udVLwLDnWw4J5KXwJgEuGxHdd+cuABw=="],
|
||||
|
||||
"@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
|
||||
@@ -6818,6 +6820,8 @@
|
||||
|
||||
"@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
@@ -7656,6 +7660,20 @@
|
||||
|
||||
"@autumn/server/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260510.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YpG99bf/Va1aLGP8SUQy1ClUvi4c6uTFrEQ0B5KzZb9TsOwH1RIrc/2n8UO3IAuilvwEA0EU4q8fEO3otVP2Sw=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260510.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-NUwhwHpQn7aSX2GGBuY2bjec+hFnIz2DAna4ksVneexVE20h2U0MFzBvWrqH2C0PzPxVvGOMg4fGCvhTs93nlw=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "arm" }, "sha512-UE+PIWWg7vvszSU0gS9rzgIIHCWexz3hMZDHpHRSLAleAvULCNI3EzwTRFOA4BHyQ8eReD1KZ8e76BuStEPspw=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-T7Zuy6h0sU+38w+N3A+YgW0XVqxIMjeHyu+945rJkiP9zk52Mwp663t1ndyeAE/N2zV+q0SWQmHNuFSXl99wJw=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gJu4q4YREvjR2Lx1jUaCd/bRbTuyKf2r3rJ4tReuHyAvNse23HdGI0a9w4Z3wUbvRznxYt640IIItWsr/f3LEQ=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260510.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-00DtjrtkdAHOU/soYr8ncrjUvIsple8nvb29ZUATnLraNnzUgv5AS3yMve/pG/N7rVLlKy2FrXlVyVW7WAx29w=="],
|
||||
|
||||
"@autumn/server/@typescript/native-preview/@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260510.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27UeujQTEPFxhfkZL7aHnA1TlNol3nwDVFp5d6jFoP14yTXMe47kBnAJLEU2ta3REZE5PzLCs7HLV8H4VdxGgA=="],
|
||||
|
||||
"@autumn/server/autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@autumn/server/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],
|
||||
|
||||
17
server/src/external/redis/initRedisV2.ts
vendored
17
server/src/external/redis/initRedisV2.ts
vendored
@@ -8,24 +8,19 @@ import {
|
||||
waitForRedisReady,
|
||||
} from "./initRedis.js";
|
||||
import {
|
||||
getRedisV2ConnectionConfig,
|
||||
REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
supportsUpstashShebangForRedisV2,
|
||||
} from "./initUtils/redisV2Config.js";
|
||||
|
||||
const redisV2Config = getRedisV2ConnectionConfig({
|
||||
cacheV2Url: process.env.CACHE_V2_DRAGONFLY_URL,
|
||||
primaryCacheUrl: process.env.CACHE_URL,
|
||||
currentRegion,
|
||||
export const redisV2: Redis = createRedisConnection({
|
||||
cacheUrl: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || "",
|
||||
region: `${currentRegion}:v2`,
|
||||
supportsUpstashShebang: false,
|
||||
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
export const hasRedisV2Config = Boolean(redisV2Config);
|
||||
|
||||
export const redisV2: Redis = redisV2Config
|
||||
? createRedisConnection(redisV2Config)
|
||||
: redis;
|
||||
|
||||
const alternateInstanceUrls: Partial<Record<RedisV2InstanceName, string>> = {
|
||||
upstash: process.env.CACHE_V2_UPSTASH_URL?.trim() || undefined,
|
||||
redis: process.env.CACHE_V2_REDIS_URL?.trim() || undefined,
|
||||
dragonfly: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || undefined,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const formatRedisEndpoint = ({ cacheUrl }: { cacheUrl: string }) => {
|
||||
export const createRedisClient = ({
|
||||
cacheUrl,
|
||||
region,
|
||||
supportsUpstashShebang = true,
|
||||
supportsUpstashShebang = false,
|
||||
commandTimeout = REDIS_COMMAND_TIMEOUT_MS,
|
||||
}: {
|
||||
cacheUrl: string;
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
hasRedisV2Config,
|
||||
redisV2,
|
||||
} from "../initRedisV2.js";
|
||||
import { redisV2 } from "../initRedisV2.js";
|
||||
import {
|
||||
createRedisAvailability,
|
||||
type RedisAvailabilitySnapshot,
|
||||
} from "./createRedisAvailability.js";
|
||||
import {
|
||||
getRedisAvailability,
|
||||
shouldUseRedis,
|
||||
} from "./redisAvailability.js";
|
||||
import { getRedisAvailability, shouldUseRedis } from "./redisAvailability.js";
|
||||
import { redis as primaryRedis } from "./redisClientRegistry.js";
|
||||
|
||||
const usesPrimaryRedis = redisV2 === primaryRedis;
|
||||
@@ -18,7 +12,7 @@ const getPrimaryBackedRedisV2Availability = (): RedisAvailabilitySnapshot => {
|
||||
const availability = getRedisAvailability();
|
||||
|
||||
return {
|
||||
configured: hasRedisV2Config,
|
||||
configured: true,
|
||||
state: availability.state,
|
||||
status: availability.status,
|
||||
};
|
||||
@@ -34,7 +28,7 @@ const redisV2Availability = usesPrimaryRedis
|
||||
}
|
||||
: createRedisAvailability({
|
||||
redis: redisV2,
|
||||
hasConfig: hasRedisV2Config,
|
||||
hasConfig: true,
|
||||
logPrefix: "RedisV2",
|
||||
logType: "redis_v2_availability_state_set",
|
||||
});
|
||||
@@ -50,7 +44,7 @@ const {
|
||||
export {
|
||||
getRedisV2Availability,
|
||||
primeRedisV2Monitor,
|
||||
shouldUseRedisV2,
|
||||
startRedisV2Monitor,
|
||||
stopRedisV2Monitor,
|
||||
shouldUseRedisV2,
|
||||
};
|
||||
|
||||
@@ -5,18 +5,18 @@ export const REDIS_V2_COMMAND_TIMEOUT_MS =
|
||||
|
||||
export const getRedisV2ConnectionConfig = ({
|
||||
cacheV2Url,
|
||||
primaryCacheUrl,
|
||||
currentRegion,
|
||||
instanceName,
|
||||
}: {
|
||||
cacheV2Url?: string;
|
||||
primaryCacheUrl?: string;
|
||||
currentRegion: string;
|
||||
instanceName: RedisV2InstanceName;
|
||||
}) =>
|
||||
cacheV2Url?.trim() && cacheV2Url.trim() !== primaryCacheUrl?.trim()
|
||||
cacheV2Url?.trim()
|
||||
? {
|
||||
cacheUrl: cacheV2Url.trim(),
|
||||
region: `${currentRegion}:v2`,
|
||||
supportsUpstashShebang: supportsUpstashShebangForRedisV2("upstash"),
|
||||
supportsUpstashShebang: supportsUpstashShebangForRedisV2(instanceName),
|
||||
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
}
|
||||
: null;
|
||||
|
||||
2
server/src/external/redis/resolveRedisV2.ts
vendored
2
server/src/external/redis/resolveRedisV2.ts
vendored
@@ -22,7 +22,7 @@ export const resolveRedisV2 = (): Redis => {
|
||||
lastLoggedInstance = activeInstance;
|
||||
}
|
||||
|
||||
if (activeInstance === "upstash") return redisV2Primary;
|
||||
if (activeInstance === "dragonfly") return redisV2Primary;
|
||||
|
||||
const alternate = getAlternateRedisV2Instance(activeInstance);
|
||||
return alternate ?? redisV2Primary;
|
||||
|
||||
@@ -19,9 +19,14 @@ export type SkipSubscriptionSyncResult =
|
||||
export const shouldSkipSubscriptionSync = ({
|
||||
subscription,
|
||||
fullCustomer,
|
||||
requireRecent = true,
|
||||
}: {
|
||||
subscription: Stripe.Subscription;
|
||||
fullCustomer: FullCustomer;
|
||||
/** For sub.created, pass false: any prior Autumn management is enough to
|
||||
* skip. For sub.updated (default), only a recent stamp suppresses sync so
|
||||
* later genuine changes still get picked up. */
|
||||
requireRecent?: boolean;
|
||||
}): SkipSubscriptionSyncResult => {
|
||||
const alreadyLinked = fullCustomer.customer_products?.some(
|
||||
(customerProduct) =>
|
||||
@@ -33,6 +38,7 @@ export const shouldSkipSubscriptionSync = ({
|
||||
|
||||
const metadataDecision = isAutumnManagedSubscriptionMetadata({
|
||||
metadata: subscription.metadata,
|
||||
requireRecent,
|
||||
});
|
||||
if (metadataDecision.skip) {
|
||||
return { skip: true, reason: metadataDecision.reason ?? "autumn metadata" };
|
||||
|
||||
@@ -3,7 +3,6 @@ import type Stripe from "stripe";
|
||||
import { ProductService } from "@/internal/products/ProductService.js";
|
||||
import { getFullStripeSub } from "../../stripeSubUtils.js";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { shouldSkipSubscriptionSync } from "../common/subscriptionSync/shouldSkipSubscriptionSync.js";
|
||||
|
||||
export type StripeSubscriptionCreatedContext = {
|
||||
subscription: Stripe.Subscription;
|
||||
@@ -16,7 +15,7 @@ export const setupStripeSubscriptionCreatedContext = async ({
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
}): Promise<StripeSubscriptionCreatedContext | undefined> => {
|
||||
const { db, org, env, fullCustomer, stripeCli, stripeEvent, logger } = ctx;
|
||||
const { db, org, env, fullCustomer, stripeCli, stripeEvent } = ctx;
|
||||
const stripeObject = stripeEvent.data.object as Stripe.Subscription;
|
||||
|
||||
// No auto-provisioning — only sync subs for customers already in Autumn.
|
||||
@@ -27,13 +26,5 @@ export const setupStripeSubscriptionCreatedContext = async ({
|
||||
ProductService.listFull({ db, orgId: org.id, env }),
|
||||
]);
|
||||
|
||||
const skip = shouldSkipSubscriptionSync({ subscription, fullCustomer });
|
||||
if (skip.skip) {
|
||||
logger.info(
|
||||
`sub.created auto-sync: skipping stripe sub ${subscription.id} (${skip.reason})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { subscription, fullCustomer, candidateProducts };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync.js";
|
||||
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js";
|
||||
import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.js";
|
||||
import { shouldSkipSubscriptionSync } from "../../common/subscriptionSync/shouldSkipSubscriptionSync.js";
|
||||
import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js";
|
||||
|
||||
/**
|
||||
@@ -21,10 +23,29 @@ export const autoSyncFromSubscription = async ({
|
||||
ctx: StripeWebhookContext;
|
||||
subscriptionCreatedContext: StripeSubscriptionCreatedContext;
|
||||
}) => {
|
||||
const { logger } = ctx;
|
||||
const { logger, stripeCli } = ctx;
|
||||
const { subscription, fullCustomer } = subscriptionCreatedContext;
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const skip = shouldSkipSubscriptionSync({
|
||||
subscription,
|
||||
fullCustomer,
|
||||
requireRecent: false,
|
||||
});
|
||||
if (skip.skip) {
|
||||
logger.info(
|
||||
`sub.created auto-sync skipping ${subscription.id} (${skip.reason})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isAutumnCheckoutSubscription({ stripeCli, subscription })) {
|
||||
logger.info(
|
||||
`sub.created auto-sync skipping ${subscription.id}: originated from Autumn checkout session`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { match, params } = await subscriptionToSyncParams({
|
||||
ctx,
|
||||
customerId,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* True when `subscription` was created by an Autumn-managed Checkout Session.
|
||||
*
|
||||
* Why: `checkout.session.completed` materializes the cus_product itself, so
|
||||
* auto-sync from `customer.subscription.created` would race and produce a
|
||||
* duplicate row on the same Stripe sub.
|
||||
*/
|
||||
export const isAutumnCheckoutSubscription = async ({
|
||||
stripeCli,
|
||||
subscription,
|
||||
}: {
|
||||
stripeCli: Stripe;
|
||||
subscription: Stripe.Subscription;
|
||||
}): Promise<boolean> => {
|
||||
const sessions = await stripeCli.checkout.sessions.list({
|
||||
subscription: subscription.id,
|
||||
limit: 1,
|
||||
});
|
||||
const session = sessions.data[0];
|
||||
return Boolean(session?.metadata?.autumn_metadata_id);
|
||||
};
|
||||
@@ -26,14 +26,23 @@ export const buildAutumnSubscriptionMetadata = ({
|
||||
return meta;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param requireRecent — when true (default), `autumn_managed_at` only counts
|
||||
* if it falls within `windowMs`. Used by sub.updated where a stale stamp
|
||||
* shouldn't suppress a genuinely new change. Pass false from sub.created:
|
||||
* once a sub has ever been Autumn-managed, auto-sync should never run on
|
||||
* its creation event.
|
||||
*/
|
||||
export const isAutumnManagedSubscriptionMetadata = ({
|
||||
metadata,
|
||||
windowMs = RECENT_AUTUMN_ACTION_WINDOW_MS,
|
||||
now = Date.now(),
|
||||
requireRecent = true,
|
||||
}: {
|
||||
metadata: Stripe.Metadata | null | undefined;
|
||||
windowMs?: number;
|
||||
now?: number;
|
||||
requireRecent?: boolean;
|
||||
}): { skip: boolean; reason?: string } => {
|
||||
if (!metadata) return { skip: false };
|
||||
|
||||
@@ -49,10 +58,14 @@ export const isAutumnManagedSubscriptionMetadata = ({
|
||||
if (!managedAtRaw) return { skip: false };
|
||||
|
||||
const managedAt = Number(managedAtRaw);
|
||||
if (!Number.isFinite(managedAt) || now - managedAt >= windowMs) {
|
||||
return { skip: false };
|
||||
if (!Number.isFinite(managedAt)) return { skip: false };
|
||||
|
||||
if (!requireRecent) {
|
||||
return { skip: true, reason: `autumn_managed_at present (source=unknown)` };
|
||||
}
|
||||
|
||||
if (now - managedAt >= windowMs) return { skip: false };
|
||||
|
||||
return {
|
||||
skip: true,
|
||||
reason: `recent autumn_managed_at (${now - managedAt}ms ago, source=unknown)`,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Autumn } from "autumn-js";
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleCreatePlatformOrg } from "./handlers/handleCreatePlatformOrg.js";
|
||||
@@ -24,12 +23,13 @@ platformBetaRouter.use("*", async (c, next) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const autumn = new Autumn();
|
||||
const { allowed } = await autumn.check({
|
||||
customerId: org.id,
|
||||
featureId: "platform",
|
||||
});
|
||||
// const autumn = new Autumn();
|
||||
// const { allowed } = await autumn.check({
|
||||
// customerId: org.id,
|
||||
// featureId: "platform",
|
||||
// });
|
||||
|
||||
const allowed = true;
|
||||
if (!allowed) {
|
||||
return c.json(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { timeout } from "@tests/utils/genUtils";
|
||||
import ctx, {
|
||||
type TestContext,
|
||||
} from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import type Stripe from "stripe";
|
||||
import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
const getLatestStripeSubscription = async ({
|
||||
ctx,
|
||||
customerId,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
}): Promise<Stripe.Subscription> => {
|
||||
const fullCustomer = await CusService.getFull({
|
||||
ctx,
|
||||
idOrInternalId: customerId,
|
||||
});
|
||||
const stripeCustomerId = fullCustomer.processor?.id;
|
||||
if (!stripeCustomerId) {
|
||||
throw new Error(`Customer ${customerId} has no Stripe customer ID`);
|
||||
}
|
||||
const subs = await ctx.stripeCli.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
limit: 1,
|
||||
});
|
||||
const sub = subs.data[0];
|
||||
if (!sub) throw new Error(`Customer ${customerId} has no Stripe subs`);
|
||||
return sub;
|
||||
};
|
||||
|
||||
test(`${chalk.yellowBright("isAutumnCheckoutSubscription: true for sub from Autumn checkout")}`, async () => {
|
||||
const customerId = "checkout-guard-positive";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
ctx,
|
||||
setup: [s.customer({ testClock: true }), s.products({ list: [pro] })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const attachResult = await autumnV1.billing.attach(
|
||||
{ customer_id: customerId, product_id: pro.id },
|
||||
{ timeout: 0 },
|
||||
);
|
||||
expect(attachResult.payment_url).toContain("checkout.stripe.com");
|
||||
|
||||
await completeStripeCheckoutFormV2({ url: attachResult.payment_url! });
|
||||
await timeout(8000);
|
||||
|
||||
const subscription = await getLatestStripeSubscription({ ctx, customerId });
|
||||
|
||||
const isFromCheckout = await isAutumnCheckoutSubscription({
|
||||
stripeCli: ctx.stripeCli,
|
||||
subscription,
|
||||
});
|
||||
expect(isFromCheckout).toBe(true);
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("isAutumnCheckoutSubscription: false for sub created directly")}`, async () => {
|
||||
const customerId = "checkout-guard-negative";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 100 })],
|
||||
});
|
||||
|
||||
await initScenario({
|
||||
customerId,
|
||||
ctx,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const subscription = await getLatestStripeSubscription({ ctx, customerId });
|
||||
|
||||
const isFromCheckout = await isAutumnCheckoutSubscription({
|
||||
stripeCli: ctx.stripeCli,
|
||||
subscription,
|
||||
});
|
||||
expect(isFromCheckout).toBe(false);
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
buildFullSubjectKey,
|
||||
buildFullSubjectViewEpochKey,
|
||||
getCachedFullSubject,
|
||||
getOrSetCachedFullSubject,
|
||||
invalidateCachedFullSubject,
|
||||
} from "@/internal/customers/cache/fullSubject/index.js";
|
||||
import { cleanupFullSubjectScenario } from "../../integration/db/full-subject/utils/cleanupFullSubjectScenario.js";
|
||||
import { buildEntitySubjectScenario } from "../../integration/db/full-subject/utils/fullSubjectScenarioBuilders.js";
|
||||
import { insertFullSubjectScenario } from "../../integration/db/full-subject/utils/insertFullSubjectScenario.js";
|
||||
import { normalizedToCachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js";
|
||||
import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js";
|
||||
import { cleanupFullSubjectScenario } from "../../db/full-subject/utils/cleanupFullSubjectScenario.js";
|
||||
import { buildEntitySubjectScenario } from "../../db/full-subject/utils/fullSubjectScenarioBuilders.js";
|
||||
import { insertFullSubjectScenario } from "../../db/full-subject/utils/insertFullSubjectScenario.js";
|
||||
|
||||
const describeDb = process.env.TESTS_ORG ? describe : describe.skip;
|
||||
|
||||
@@ -24,6 +25,47 @@ describeDb("invalidateCachedFullSubject", () => {
|
||||
let ctx: TestContext;
|
||||
let scenario: ReturnType<typeof buildEntitySubjectScenario>;
|
||||
|
||||
const cleanupScenarioState = async () => {
|
||||
const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`);
|
||||
if (customerKeys.length > 0) await ctx.redisV2.unlink(...customerKeys);
|
||||
|
||||
await cleanupFullSubjectScenario({ ctx, scenario });
|
||||
};
|
||||
|
||||
const getSubjectViewEpoch = async () => {
|
||||
const epoch = await ctx.redisV2.get(
|
||||
buildFullSubjectViewEpochKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: scenario.ids.customerId,
|
||||
}),
|
||||
);
|
||||
|
||||
return epoch ? Number.parseInt(epoch, 10) : 0;
|
||||
};
|
||||
|
||||
const seedCachedFullSubject = async ({ entityId }: { entityId?: string } = {}) => {
|
||||
const result = await getFullSubjectNormalized({
|
||||
ctx,
|
||||
customerId: scenario.ids.customerId,
|
||||
entityId,
|
||||
});
|
||||
if (!result) throw new Error("Failed to build full subject cache fixture");
|
||||
|
||||
const cached = normalizedToCachedFullSubject({
|
||||
normalized: result.normalized,
|
||||
subjectViewEpoch: await getSubjectViewEpoch(),
|
||||
});
|
||||
const subjectKey = buildFullSubjectKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: scenario.ids.customerId,
|
||||
entityId,
|
||||
});
|
||||
|
||||
await ctx.redisV2.set(subjectKey, JSON.stringify(cached));
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const { createTestContext } = await import(
|
||||
"@tests/utils/testInitUtils/createTestContext.js"
|
||||
@@ -36,36 +78,19 @@ describeDb("invalidateCachedFullSubject", () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupScenarioState();
|
||||
await insertFullSubjectScenario({ ctx, scenario });
|
||||
await getOrSetCachedFullSubject({
|
||||
ctx,
|
||||
customerId: scenario.ids.customerId,
|
||||
source: "invalidateCachedFullSubjectTest",
|
||||
});
|
||||
await getOrSetCachedFullSubject({
|
||||
ctx,
|
||||
customerId: scenario.ids.customerId,
|
||||
entityId: scenario.ids.entityIds[0],
|
||||
source: "invalidateCachedFullSubjectTest",
|
||||
});
|
||||
await getOrSetCachedFullSubject({
|
||||
ctx,
|
||||
customerId: scenario.ids.customerId,
|
||||
entityId: scenario.ids.entityIds[1],
|
||||
source: "invalidateCachedFullSubjectTest",
|
||||
});
|
||||
await seedCachedFullSubject();
|
||||
await seedCachedFullSubject({ entityId: scenario.ids.entityIds[0] });
|
||||
await seedCachedFullSubject({ entityId: scenario.ids.entityIds[1] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`);
|
||||
if (customerKeys.length > 0) {
|
||||
await ctx.redisV2.unlink(...customerKeys);
|
||||
}
|
||||
|
||||
await cleanupFullSubjectScenario({ ctx, scenario });
|
||||
await cleanupScenarioState();
|
||||
});
|
||||
|
||||
test("invalidates direct entity cache and increments subject view epoch", async () => {
|
||||
const initialSubjectViewEpoch = await getSubjectViewEpoch();
|
||||
const entityAKey = buildFullSubjectKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
@@ -93,18 +118,11 @@ describeDb("invalidateCachedFullSubject", () => {
|
||||
expect(await ctx.redisV2.exists(customerKey)).toBe(0);
|
||||
expect(await ctx.redisV2.exists(entityAKey)).toBe(0);
|
||||
expect(await ctx.redisV2.exists(entityBKey)).toBe(1);
|
||||
expect(
|
||||
await ctx.redisV2.get(
|
||||
buildFullSubjectViewEpochKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: scenario.ids.customerId,
|
||||
}),
|
||||
),
|
||||
).toBe("1");
|
||||
expect(await getSubjectViewEpoch()).toBe(initialSubjectViewEpoch + 1);
|
||||
});
|
||||
|
||||
test("increments subject view epoch for customer invalidation", async () => {
|
||||
const initialSubjectViewEpoch = await getSubjectViewEpoch();
|
||||
const entityAKey = buildFullSubjectKey({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
@@ -131,7 +149,9 @@ describeDb("invalidateCachedFullSubject", () => {
|
||||
|
||||
expect(await ctx.redisV2.exists(entityAKey)).toBe(1);
|
||||
expect(await ctx.redisV2.exists(entityBKey)).toBe(1);
|
||||
expect(await ctx.redisV2.get(epochKey)).toBe("1");
|
||||
expect(Number.parseInt((await ctx.redisV2.get(epochKey)) ?? "0", 10)).toBe(
|
||||
initialSubjectViewEpoch + 1,
|
||||
);
|
||||
});
|
||||
|
||||
test("sibling entity cache becomes stale after direct entity invalidation", async () => {
|
||||
@@ -17,7 +17,8 @@ import chalk from "chalk";
|
||||
import { setupUpdateSubscriptionIntent } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionIntent";
|
||||
|
||||
const customerProduct = {
|
||||
prices: [],
|
||||
customer_prices: [],
|
||||
customer_entitlements: [],
|
||||
} as unknown as FullCusProduct;
|
||||
|
||||
const baseParams: UpdateSubscriptionV1Params = {
|
||||
|
||||
@@ -1,43 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getRedisV2ConnectionConfig,
|
||||
REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
supportsUpstashShebangForRedisV2,
|
||||
} from "@/external/redis/initUtils/redisV2Config.js";
|
||||
import { supportsUpstashShebangForRedisV2 } from "@/external/redis/initUtils/redisV2Config.js";
|
||||
|
||||
describe("redis V2 connection config", () => {
|
||||
test("uses a distinct CACHE_V2_UPSTASH_URL with the Upstash shebang", () => {
|
||||
expect(
|
||||
getRedisV2ConnectionConfig({
|
||||
cacheV2Url: " redis://v2 ",
|
||||
primaryCacheUrl: "redis://primary",
|
||||
currentRegion: "us-west-2",
|
||||
}),
|
||||
).toEqual({
|
||||
cacheUrl: "redis://v2",
|
||||
region: "us-west-2:v2",
|
||||
supportsUpstashShebang: true,
|
||||
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to primary Redis when CACHE_V2_UPSTASH_URL is absent or matches primary", () => {
|
||||
expect(
|
||||
getRedisV2ConnectionConfig({
|
||||
cacheV2Url: undefined,
|
||||
primaryCacheUrl: "redis://primary",
|
||||
currentRegion: "us-west-2",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getRedisV2ConnectionConfig({
|
||||
cacheV2Url: " redis://primary ",
|
||||
primaryCacheUrl: "redis://primary",
|
||||
currentRegion: "us-west-2",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("enables the Upstash shebang only for the upstash instance", () => {
|
||||
expect(supportsUpstashShebangForRedisV2("upstash")).toBe(true);
|
||||
expect(supportsUpstashShebangForRedisV2("redis")).toBe(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ export const useAutumnFlags = () => {
|
||||
|
||||
const [flags, setFlags] = useLocalStorage("autumn.flags", {
|
||||
pkey: false,
|
||||
webhooks: false,
|
||||
webhooks: true,
|
||||
stripe_key: false,
|
||||
platform: false,
|
||||
vercel: false,
|
||||
@@ -20,7 +20,7 @@ export const useAutumnFlags = () => {
|
||||
|
||||
const nextFlags = {
|
||||
pkey: notNullish(customer.flags.pkey),
|
||||
webhooks: notNullish(customer.flags.webhooks),
|
||||
webhooks: true,
|
||||
stripe_key: notNullish(customer.flags.stripe_key),
|
||||
platform: notNullish(customer.flags.platform),
|
||||
vercel: notNullish(customer.flags.vercel),
|
||||
@@ -40,5 +40,5 @@ export const useAutumnFlags = () => {
|
||||
}
|
||||
}, [customer?.flags]);
|
||||
|
||||
return flags;
|
||||
return { ...flags, webhooks: true };
|
||||
};
|
||||
|
||||
@@ -82,10 +82,10 @@ export const SavedViews = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="p-0 pt-1 px-3">
|
||||
<span className="text-t3 text-xs">Saved views</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<DropdownMenuLabel className="p-0 pt-1 px-3">
|
||||
<span className="text-t3 text-xs">Saved views</span>
|
||||
</DropdownMenuLabel>
|
||||
{views.map((view: SavedView) => (
|
||||
<div
|
||||
key={view.id}
|
||||
|
||||
Reference in New Issue
Block a user