diff --git a/ai b/ai index 761b84255..008fd7a29 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 761b842553d890355b77e618c3aa703dddb661c1 +Subproject commit 008fd7a292eae7088e874ea86df4f251165e1f59 diff --git a/bun.lock b/bun.lock index f2b00dc26..95282c80e 100644 --- a/bun.lock +++ b/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=="], diff --git a/server/src/external/redis/initRedisV2.ts b/server/src/external/redis/initRedisV2.ts index adf5b91f2..49fd6f09b 100644 --- a/server/src/external/redis/initRedisV2.ts +++ b/server/src/external/redis/initRedisV2.ts @@ -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> = { + 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, }; diff --git a/server/src/external/redis/initUtils/createRedisClient.ts b/server/src/external/redis/initUtils/createRedisClient.ts index b611d9917..098cd4d49 100644 --- a/server/src/external/redis/initUtils/createRedisClient.ts +++ b/server/src/external/redis/initUtils/createRedisClient.ts @@ -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; diff --git a/server/src/external/redis/initUtils/redisV2Availability.ts b/server/src/external/redis/initUtils/redisV2Availability.ts index 9784ea723..d179c825e 100644 --- a/server/src/external/redis/initUtils/redisV2Availability.ts +++ b/server/src/external/redis/initUtils/redisV2Availability.ts @@ -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, }; diff --git a/server/src/external/redis/initUtils/redisV2Config.ts b/server/src/external/redis/initUtils/redisV2Config.ts index f458be3c6..ae8d44506 100644 --- a/server/src/external/redis/initUtils/redisV2Config.ts +++ b/server/src/external/redis/initUtils/redisV2Config.ts @@ -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; diff --git a/server/src/external/redis/resolveRedisV2.ts b/server/src/external/redis/resolveRedisV2.ts index 38f2f4098..14d91f697 100644 --- a/server/src/external/redis/resolveRedisV2.ts +++ b/server/src/external/redis/resolveRedisV2.ts @@ -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; diff --git a/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts b/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts index b0b65cdb6..598913e0f 100644 --- a/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts +++ b/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts @@ -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" }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts index 68d01001f..b29f7e699 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts @@ -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 => { - 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 }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts index b899de6ad..0e5fac446 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts b/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts new file mode 100644 index 000000000..6024f0b14 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts @@ -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 => { + const sessions = await stripeCli.checkout.sessions.list({ + subscription: subscription.id, + limit: 1, + }); + const session = sessions.data[0]; + return Boolean(session?.metadata?.autumn_metadata_id); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts index 819135906..bdc85f329 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts @@ -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)`, diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts index ea55f069d..f620a7b0d 100644 --- a/server/src/internal/platform/platformBeta/platformBetaRouter.ts +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -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( { diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts new file mode 100644 index 000000000..98b396268 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts @@ -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 => { + 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); +}); diff --git a/server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts b/server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts similarity index 64% rename from server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts rename to server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts index d1b50e16c..cfcd1144d 100644 --- a/server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts +++ b/server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts @@ -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; + 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 () => { diff --git a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts index 57cbfba8f..eb582e773 100644 --- a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts +++ b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts @@ -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 = { diff --git a/server/tests/unit/redis/redis-v2-config.spec.ts b/server/tests/unit/redis/redis-v2-config.spec.ts index 9e599f3a4..ccc8d3693 100644 --- a/server/tests/unit/redis/redis-v2-config.spec.ts +++ b/server/tests/unit/redis/redis-v2-config.spec.ts @@ -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); diff --git a/vite/src/hooks/common/useAutumnFlags.tsx b/vite/src/hooks/common/useAutumnFlags.tsx index 265632458..9a3bac22a 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -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 }; }; diff --git a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx index 0aa6b5ef9..8f435445e 100644 --- a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx +++ b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx @@ -82,10 +82,10 @@ export const SavedViews = ({ return ( <> - - Saved views - + + Saved views + {views.map((view: SavedView) => (