diff --git a/scripts/setup/AGENT_README.md b/scripts/setup/AGENT_README.md index 5e9de40de..aabcf0130 100644 --- a/scripts/setup/AGENT_README.md +++ b/scripts/setup/AGENT_README.md @@ -58,7 +58,7 @@ Set these in the process environment before running `bun dev:agent` and they wil | PostgreSQL | 5432 | Database: `autumn`, user: `postgres`, password: `postgres` | | Redis Stack | 6379 | Used for `CACHE_URL` and `CACHE_URL_US_EAST` (RedisJSON required) | | ElasticMQ | 9324 | Local SQS replacement, queue: `autumn.fifo` | -| ClickHouse | 8123 | Used for `TINYBIRD_US_EAST_CLICKHOUSE_URL` | +| ClickHouse | 8123 | Used for `TINYBIRD_CLICKHOUSE_URL` | | Server | 8080 | Autumn API server | | Vite | 3000 | Frontend dev server | | Checkout | 3001 | Checkout app dev server | diff --git a/scripts/setup/writeAgentEnv.ts b/scripts/setup/writeAgentEnv.ts index 082f40e35..8a09837fb 100644 --- a/scripts/setup/writeAgentEnv.ts +++ b/scripts/setup/writeAgentEnv.ts @@ -67,7 +67,7 @@ AWS_ACCESS_KEY_ID=x AWS_SECRET_ACCESS_KEY=x # ClickHouse (local) -TINYBIRD_US_EAST_CLICKHOUSE_URL=http://localhost:8123 +TINYBIRD_CLICKHOUSE_URL=http://localhost:8123 # App URLs BETTER_AUTH_URL=http://localhost:${serverPort} diff --git a/server/src/external/tinybird/initClickhouse.ts b/server/src/external/tinybird/initClickhouse.ts index d05f3454b..5afb4f595 100644 --- a/server/src/external/tinybird/initClickhouse.ts +++ b/server/src/external/tinybird/initClickhouse.ts @@ -3,8 +3,8 @@ import { type ClickHouseClient, createClient } from "@clickhouse/client"; // ClickHouse URL is different from API URL // API: https://api.europe-west2.gcp.tinybird.co // ClickHouse: https://europe-west2.gcp.clickhouse.tinybird.co -const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_US_EAST_CLICKHOUSE_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; +const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_CLICKHOUSE_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; // Debug logging if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { diff --git a/server/src/external/tinybird/initTinybirdV2.ts b/server/src/external/tinybird/initTinybirdV2.ts index 4248732f7..e2984697d 100644 --- a/server/src/external/tinybird/initTinybirdV2.ts +++ b/server/src/external/tinybird/initTinybirdV2.ts @@ -1,19 +1,19 @@ import { createTinybirdApi } from "@tinybirdco/sdk"; -// Dual-write safety net during the us-east cutover. Reads the legacy -// us-west env vars; delete with the dual-write logic in `sendEvents.ts` -// once us-east is stable. -const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; +const TINYBIRD_US_EAST_API_URL = process.env.TINYBIRD_US_EAST_API_URL; +const TINYBIRD_US_EAST_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; -/** Secondary Tinybird API client for dual-write during region cutover. */ -export const tinybirdSecondaryApi = - TINYBIRD_API_URL && TINYBIRD_TOKEN - ? createTinybirdApi({ baseUrl: TINYBIRD_API_URL, token: TINYBIRD_TOKEN }) +/** Secondary Tinybird API client (us-east region) for dual-write during migration. */ +export const tinybirdUsEastApi = + TINYBIRD_US_EAST_API_URL && TINYBIRD_US_EAST_TOKEN + ? createTinybirdApi({ + baseUrl: TINYBIRD_US_EAST_API_URL, + token: TINYBIRD_US_EAST_TOKEN, + }) : null; -if (tinybirdSecondaryApi) { +if (tinybirdUsEastApi) { console.log( - `[Tinybird] secondary dual-write configured with URL: ${TINYBIRD_API_URL}`, + `[Tinybird] us-east dual-write configured with URL: ${TINYBIRD_US_EAST_API_URL}`, ); } diff --git a/server/src/external/tinybird/sendEvents/sendEvents.ts b/server/src/external/tinybird/sendEvents/sendEvents.ts index 60a32f93c..a30a31137 100644 --- a/server/src/external/tinybird/sendEvents/sendEvents.ts +++ b/server/src/external/tinybird/sendEvents/sendEvents.ts @@ -3,7 +3,7 @@ import type { EventInsert } from "@autumn/shared"; import * as Sentry from "@sentry/bun"; import type { Logger } from "@/external/logtail/logtailUtils.js"; import { tinybirdIngest } from "../initTinybird.js"; -import { tinybirdSecondaryApi } from "../initTinybirdV2.js"; +import { tinybirdUsEastApi } from "../initTinybirdV2.js"; import { isTinybirdConfigured } from "../tinybirdUtils.js"; import { mapToTinybirdEvent } from "./mapEvent.js"; @@ -35,7 +35,7 @@ export const sendEventsToTinybird = async ({ const tinybirdEvents = events.map(mapToTinybirdEvent); - const reportFailure = (error: unknown, region: "primary" | "secondary") => { + const reportFailure = (error: unknown, region: "primary" | "us-east") => { const errorId = generateErrorId(); const errorMessage = error instanceof Error ? error.message : String(error); @@ -81,21 +81,24 @@ export const sendEventsToTinybird = async ({ }) .catch((error: unknown) => reportFailure(error, "primary")); - const secondaryWrite = tinybirdSecondaryApi - ? tinybirdSecondaryApi + const usEastWrite = tinybirdUsEastApi + ? tinybirdUsEastApi .ingestBatch("events", tinybirdEvents) .then((result) => { - logger?.info(`Sent ${events.length} events to Tinybird (secondary)`, { - data: { - region: "secondary", - eventCount: events.length, - successfulRows: result?.successful_rows, - quarantinedRows: result?.quarantined_rows, + logger?.info( + `Sent ${events.length} events to Tinybird (us-east)`, + { + data: { + region: "us-east", + eventCount: events.length, + successfulRows: result?.successful_rows, + quarantinedRows: result?.quarantined_rows, + }, }, - }); + ); }) - .catch((error: unknown) => reportFailure(error, "secondary")) + .catch((error: unknown) => reportFailure(error, "us-east")) : Promise.resolve(); - await Promise.all([primaryWrite, secondaryWrite]); + await Promise.all([primaryWrite, usEastWrite]); }; diff --git a/server/src/external/tinybird/tinybirdUtils.ts b/server/src/external/tinybird/tinybirdUtils.ts index 1af3e38e2..b5d10c720 100644 --- a/server/src/external/tinybird/tinybirdUtils.ts +++ b/server/src/external/tinybird/tinybirdUtils.ts @@ -1,8 +1,8 @@ import { ErrCode, RecaseError } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; -const TINYBIRD_API_URL = process.env.TINYBIRD_US_EAST_API_URL; -const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; +const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL; +const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN; export type TinybirdConfig = { baseUrl: string; diff --git a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts index 31d46225f..093acc80b 100644 --- a/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts +++ b/server/src/external/vercel/handlers/marketplace/handleMarketplaceInvoiceNotPaid.ts @@ -1,3 +1,4 @@ +import type { FullCustomer } from "@autumn/shared"; import type Stripe from "stripe"; import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { isFirstSubscriptionInvoice } from "@/external/stripe/invoices/utils/classifyStripeInvoice.js"; @@ -49,6 +50,7 @@ export const handleMarketplaceInvoiceNotPaid = async ({ ); let customPaymentMethod: Stripe.PaymentMethod | null = null; + let customer: FullCustomer | null = null; try { const partialCustomer = await CusService.getByStripeId({ @@ -63,7 +65,7 @@ export const handleMarketplaceInvoiceNotPaid = async ({ throw new Error("Customer not found"); } - const customer = await CusService.getFull({ + customer = await CusService.getFull({ ctx, idOrInternalId: partialCustomer.internal_id, }); @@ -105,26 +107,6 @@ export const handleMarketplaceInvoiceNotPaid = async ({ }); } - // If this is the first invoice for the subscription and it failed, expire the - // optimistically-provisioned cus_product so the customer falls back to the default plan. - // Renewals are handled by Stripe dunning + customer.subscription.deleted webhook. - if (isFirstSubscriptionInvoice(invoice)) { - const existingCusProducts = await customerProductRepo.getByStripeSubId({ - db, - stripeSubId: subscription.id, - orgId: org.id, - env, - }); - - if (existingCusProducts.length > 0) { - await customerProductActions.expireAndActivateDefault({ - ctx, - customerProduct: existingCusProducts[0], - fullCustomer: customer, - }); - } - } - const product = await ProductService.getFull({ db, orgId: org.id, @@ -145,6 +127,25 @@ export const handleMarketplaceInvoiceNotPaid = async ({ // Continue anyway - we still need to report payment } + // Expire optimistically-provisioned cus_product on first-invoice failure. + // Must run outside the broad catch — swallowing this leaves active access after payment failure. + if (customer && isFirstSubscriptionInvoice(invoice)) { + const existingCusProducts = await customerProductRepo.getByStripeSubId({ + db, + stripeSubId: subscription.id, + orgId: org.id, + env, + }); + + if (existingCusProducts.length > 0) { + await customerProductActions.expireAndActivateDefault({ + ctx, + customerProduct: existingCusProducts[0], + fullCustomer: customer, + }); + } + } + if (!customPaymentMethod) { throw new Error( "Cannot resolve custom payment method for failed-invoice payment record", diff --git a/server/src/internal/analytics/actions/listByCursor.ts b/server/src/internal/analytics/actions/listByCursor.ts index 652f34986..3952ba826 100644 --- a/server/src/internal/analytics/actions/listByCursor.ts +++ b/server/src/internal/analytics/actions/listByCursor.ts @@ -105,15 +105,24 @@ export const listByCursor = async ({ if (row.deductions) { try { const parsed = JSON.parse(row.deductions); - if (Array.isArray(parsed)) { - deductions = parsed as TrackDeduction[]; - } else if (parsed && Array.isArray(parsed.list)) { - deductions = parsed.list as TrackDeduction[]; + // Tinybird's JSON column re-encodes nested-object array items + // as strings; second parse brings them back to TrackDeduction. + const rawList = Array.isArray(parsed) + ? parsed + : parsed && Array.isArray(parsed.list) + ? parsed.list + : null; + if (rawList) { + deductions = rawList.map((item: unknown) => { + if (typeof item !== "string") return item as TrackDeduction; + try { + return JSON.parse(item) as TrackDeduction; + } catch { + return item as unknown as TrackDeduction; + } + }); } - } catch { - // Invalid JSON — leave null so the caller can distinguish missing - // vs explicit empty. - } + } catch {} } lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp); diff --git a/server/src/internal/analytics/actions/listEvents.ts b/server/src/internal/analytics/actions/listEvents.ts index 4408c1213..2fe69ec70 100644 --- a/server/src/internal/analytics/actions/listEvents.ts +++ b/server/src/internal/analytics/actions/listEvents.ts @@ -91,15 +91,24 @@ export const listEvents = async ({ if (row.deductions) { try { const parsed = JSON.parse(row.deductions); - if (Array.isArray(parsed)) { - deductions = parsed as TrackDeduction[]; - } else if (parsed && Array.isArray(parsed.list)) { - deductions = parsed.list as TrackDeduction[]; + // Tinybird's JSON column re-encodes nested-object array items + // as strings; second parse brings them back to TrackDeduction. + const rawList = Array.isArray(parsed) + ? parsed + : parsed && Array.isArray(parsed.list) + ? parsed.list + : null; + if (rawList) { + deductions = rawList.map((item: unknown) => { + if (typeof item !== "string") return item as TrackDeduction; + try { + return JSON.parse(item) as TrackDeduction; + } catch { + return item as unknown as TrackDeduction; + } + }); } - } catch { - // Invalid JSON — leave null so the caller can distinguish missing - // vs explicit empty. - } + } catch {} } return { diff --git a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts index f1aeadc5c..e7ea75a10 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalAggregateEvents.ts @@ -14,8 +14,10 @@ import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js"; import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js"; +import { ProductService } from "@/internal/products/ProductService.js"; import { CusService } from "@/internal/customers/CusService.js"; import { eventActions } from "../actions/eventActions.js"; +import { collapsePlanIdGroups } from "./utils/collapsePlanIdGroups.js"; const STANDARD_INTERVAL_DAYS: Record = { "24h": 1, @@ -129,6 +131,39 @@ export const handleInternalAggregateEvents = createRoute({ return { start: aligned.getTime(), end: now.getTime() }; })(); + let resolvedGroupBy = group_by; + if (group_by === "$customer_id") { + resolvedGroupBy = "customer_id"; + } else if (group_by === "$entity_id") { + resolvedGroupBy = "entity_id"; + } else if (group_by === "$plan_id") { + resolvedGroupBy = "plan_id"; + } + + const isPlanIdGrouping = resolvedGroupBy === "plan_id"; + let internalIdToPublicId: Record | undefined; + let planNames: Record | undefined; + let effectiveMaxGroups: number | undefined; + + if (isPlanIdGrouping) { + const allProductRows = await ProductService.listCachedAllVersions({ + db, + orgId: org.id, + env, + }); + effectiveMaxGroups = Math.max( + allProductRows.length + 1, + max_groups ?? 0, + 10, + ); + internalIdToPublicId = {}; + planNames = {}; + for (const p of allProductRows) { + internalIdToPublicId[p.internal_id] = p.id; + planNames[p.id] = p.name ?? p.id; + } + } + const [{ formatted: events, truncated }, totals] = await Promise.all([ eventActions.aggregate({ ctx, @@ -140,10 +175,10 @@ export const handleInternalAggregateEvents = createRoute({ event_names, bin_size: binSize, aggregateAll, - group_by: group_by, + group_by: resolvedGroupBy, customer, timezone: timezone, - max_groups, + max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups, }, }), eventActions.getCountAndSum({ @@ -161,9 +196,13 @@ export const handleInternalAggregateEvents = createRoute({ }), ]); + if (isPlanIdGrouping && events?.data && internalIdToPublicId) { + collapsePlanIdGroups({ events, internalIdToPublicId }); + } + // When grouping by entity_id, resolve entity names from ClickHouse let entityNames: Record | undefined; - if (group_by === "entity_id" && events?.data) { + if (resolvedGroupBy === "entity_id" && events?.data) { const entityIds = [ ...new Set( events.data @@ -184,7 +223,7 @@ export const handleInternalAggregateEvents = createRoute({ } let customerNames: Record | undefined; - if (group_by === "customer_id" && events?.data) { + if (resolvedGroupBy === "customer_id" && events?.data) { const customerIds = [ ...new Set( events.data @@ -214,6 +253,7 @@ export const handleInternalAggregateEvents = createRoute({ truncated, entityNames, customerNames, + planNames, }); }, }); diff --git a/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts b/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts new file mode 100644 index 000000000..e89d915ef --- /dev/null +++ b/server/src/internal/analytics/internalHandlers/utils/collapsePlanIdGroups.ts @@ -0,0 +1,42 @@ +import { Decimal } from "decimal.js"; +import type { ClickHouseResult } from "@autumn/shared"; + +export const collapsePlanIdGroups = ({ + events, + internalIdToPublicId, +}: { + events: ClickHouseResult; + internalIdToPublicId: Record; +}) => { + const collapsed = new Map>(); + + for (const row of events.data) { + const planId = String(row.plan_id ?? ""); + const collapsedId = + planId === "" || planId === "AUTUMN_RESERVED" + ? planId + : internalIdToPublicId[planId] ?? planId; + + const key = `${row.period}|${collapsedId}`; + const existing = collapsed.get(key); + + if (!existing) { + collapsed.set(key, { + ...row, + plan_id: collapsedId, + }); + continue; + } + + for (const [k, v] of Object.entries(row)) { + if (k === "period" || k === "plan_id") continue; + existing[k] = new Decimal(existing[k] ?? 0) + .plus(new Decimal(v as number)) + .toDecimalPlaces(10) + .toNumber(); + } + } + + events.data = Array.from(collapsed.values()); + events.rows = events.data.length; +}; diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 95f010401..9f025e7fb 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -109,6 +109,7 @@ export const executePostgresDeductionV2 = async ({ rollovers, customerEntitlements, unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, } = prepareFeatureDeductionV2({ ctx, @@ -129,6 +130,25 @@ export const executePostgresDeductionV2 = async ({ redisInstance: ctx.redisV2, }); } + // Attribute the event to the unlimited plan even though we skip + // the actual deduction. Without this, resolveInternalProductIdForEvent + // gets an empty mutation log and the event lands in "No plan". + if (unlimitedCusEnt) { + const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); + if (syntheticDelta !== 0) { + allMutationLogs.push({ + target_type: "customer_entitlement", + customer_entitlement_id: unlimitedCusEnt.id, + rollover_id: null, + entity_id: entityId ?? null, + credit_cost: 1, + balance_delta: syntheticDelta, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + }); + } + } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index 8cffcfbf1..a9501a6e4 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -120,6 +120,7 @@ export const executeRedisDeductionV2 = async ({ rollovers, customerEntitlements, unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, } = prepareFeatureDeductionV2({ ctx, @@ -140,6 +141,25 @@ export const executeRedisDeductionV2 = async ({ redisInstance: redisInstance ?? ctx.redisV2, }); } + // Attribute the event to the unlimited plan even though we skip + // the actual deduction. Without this, resolveInternalProductIdForEvent + // gets an empty mutation log and the event lands in "No plan". + if (unlimitedCusEnt) { + const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1); + if (syntheticDelta !== 0) { + allMutationLogs.push({ + target_type: "customer_entitlement", + customer_entitlement_id: unlimitedCusEnt.id, + rollover_id: null, + entity_id: entityId ?? null, + credit_cost: 1, + balance_delta: syntheticDelta, + adjustment_delta: 0, + usage_delta: 0, + value_delta: 0, + }); + } + } continue; } diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index fb0fc2eac..4b550de10 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -1,5 +1,7 @@ import { + AllowanceType, cusEntToStartingBalance, + type FullCusEntWithFullCusProduct, type FullSubject, fullSubjectToCustomerEntitlements, fullSubjectToOverageAllowedByFeatureId, @@ -58,6 +60,15 @@ export const prepareFeatureDeductionV2 = ({ }); const unlimitedFeatureIds: string[] = []; + // Track the chosen unlimited cusEnt so the deduction short-circuit can + // still attribute the event to a plan. Prefer cusEnts whose feature + // matches the tracked one (over credit-system parents); within that, + // take the first match in the already-sorted customerEntitlements list. + let unlimitedCusEntPrimary: FullCusEntWithFullCusProduct | undefined; + let unlimitedCusEntFallback: FullCusEntWithFullCusProduct | undefined; + const isUnlimitedCusEnt = (ce: FullCusEntWithFullCusProduct): boolean => + ce.entitlement.allowance_type === AllowanceType.Unlimited || + Boolean(ce.unlimited); for (const relevantFeature of relevantFeatures) { const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ @@ -67,9 +78,22 @@ export const prepareFeatureDeductionV2 = ({ if (featureUnlimited) { unlimitedFeatureIds.push(relevantFeature.id); + const matchingCusEnt = customerEntitlements.find( + (ce) => + ce.internal_feature_id === relevantFeature.internal_id && + isUnlimitedCusEnt(ce), + ); + if (!matchingCusEnt) continue; + if (relevantFeature.id === feature.id) { + unlimitedCusEntPrimary ??= matchingCusEnt; + } else { + unlimitedCusEntFallback ??= matchingCusEnt; + } } } + const unlimitedCusEnt = unlimitedCusEntPrimary ?? unlimitedCusEntFallback; + const effectiveFeatureIds = relevantFeatures.map((candidate) => candidate.id); const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({ fullSubject, @@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({ credit_cost: rollover.credit_cost, })), unlimitedFeatureIds, + unlimitedCusEnt, lock: preparedLock, }; }; diff --git a/server/src/internal/balances/utils/types/deductionTypes.ts b/server/src/internal/balances/utils/types/deductionTypes.ts index 033aea91b..58a4c5e5d 100644 --- a/server/src/internal/balances/utils/types/deductionTypes.ts +++ b/server/src/internal/balances/utils/types/deductionTypes.ts @@ -45,6 +45,11 @@ export type PreparedFeatureDeduction = { // rolloverIds: string[]; rollovers: RolloverDeduction[]; unlimitedFeatureIds: string[]; + // Chosen unlimited cusEnt to attribute events to when the deduction + // short-circuits via unlimitedFeatureIds. Prefers a cusEnt matching the + // tracked feature over a credit-system parent. Undefined when no + // unlimited cusEnt is present. + unlimitedCusEnt?: FullCusEntWithFullCusProduct; lock?: { enabled: true; lock_id?: string; diff --git a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts index d75b346ab..9bcbd8d20 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/computeSyncFuturePhases.ts @@ -84,6 +84,7 @@ export const computeSyncFuturePhases = ({ accessStartsAt: productContext.accessStartsAt, subscriptionId: stripeSubscription?.id, subscriptionScheduleId: stripeSchedule?.id, + internalEntityId: productContext.plan.internal_entity_id, }); insertCustomerProducts.push(cusProduct); phaseIds.push(cusProduct.id); diff --git a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts index bdb269d2f..2e96a31e7 100644 --- a/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts +++ b/server/src/internal/billing/v2/actions/sync/compute/initImmediateSyncCustomerProduct.ts @@ -68,6 +68,7 @@ export const initImmediateSyncCustomerProduct = ({ status: stripeSubscriptionToAutumnStatus({ stripeStatus: stripeSubscription.status, }), + internalEntityId: plan.internal_entity_id, }, }); }; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts index b2c7afe5c..aede0662a 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initCustomerProduct.ts @@ -41,9 +41,14 @@ export const initCustomerProduct = ({ onTrialEnd, } = initOptions ?? {}; - const scopedEntity = entity ?? fullCustomer.entity; - const internalEntityId = scopedEntity?.internal_id; - const entityId = scopedEntity?.id; + const internalEntityId = + initOptions?.internalEntityId ?? fullCustomer.entity?.internal_id; + const entityId = + initOptions?.internalEntityId && initOptions.internalEntityId !== fullCustomer.entity?.internal_id + ? fullCustomer.entities?.find( + (e) => e.internal_id === initOptions.internalEntityId, + )?.id + : fullCustomer.entity?.id; const startsAt = initOptions?.startsAt ?? now; const endedAt = initOptions?.endedAt; diff --git a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts index adcc97a8b..a46ea9c8a 100644 --- a/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts +++ b/server/src/internal/billing/v2/utils/initFullCustomerProduct/initScheduledCustomerProduct.ts @@ -31,6 +31,7 @@ export const initScheduledCustomerProduct = ({ externalId, subscriptionId, subscriptionScheduleId, + internalEntityId, }: { ctx: AutumnContext; fullCustomer: FullCustomer; @@ -48,6 +49,7 @@ export const initScheduledCustomerProduct = ({ * Stripe linkage and downstream actions (cancel, restore) can find it. */ subscriptionId?: string; subscriptionScheduleId?: string; + internalEntityId?: string; }): FullCusProduct => { const startsAtSecondsPrecision = truncateMsToSecondPrecision(startsAt); const endsAtSecondsPrecision = @@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({ externalId, subscriptionId, subscriptionScheduleId, + internalEntityId, }, }); }; diff --git a/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts b/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts index 7f4954bde..3abdd940f 100644 --- a/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts +++ b/server/src/internal/customers/cusProducts/repos/getByStripeSubId.ts @@ -21,9 +21,9 @@ export const getByStripeSubId = async ({ inStatuses?: string[]; }): Promise => { const data = await db.query.customerProducts.findMany({ - where: (_table, { and: dAnd, or: dOr, inArray: dInArray }) => + where: (_table, { and: dAnd, inArray: dInArray }) => dAnd( - dOr(arrayContains(customerProducts.subscription_ids, [stripeSubId])), + arrayContains(customerProducts.subscription_ids, [stripeSubId]), inStatuses ? dInArray(customerProducts.status, inStatuses) : undefined, ), with: { diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 159baae9c..455b34139 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -28,7 +28,11 @@ import { import { StatusCodes } from "http-status-codes"; import type { Logger } from "@/external/logtail/logtailUtils"; import { queryWithCache } from "@/utils/cacheUtils/queryWithCache"; -import { buildProductsCacheKey, PRODUCTS_CACHE_TTL } from "./productCacheUtils"; +import { + buildAllVersionsProductsCacheKey, + buildProductsCacheKey, + PRODUCTS_CACHE_TTL, +} from "./productCacheUtils"; import { getLatestProducts, isFreeProduct } from "./productUtils"; import { sortFullProducts } from "./productUtils/sortProductUtils"; @@ -297,6 +301,31 @@ export class ProductService { }); } + static async listCachedAllVersions({ + db, + orgId, + env, + }: { + db: DrizzleCli; + orgId: string; + env: AppEnv; + }): Promise> { + return queryWithCache({ + key: buildAllVersionsProductsCacheKey({ orgId, env }), + ttl: PRODUCTS_CACHE_TTL, + fn: () => + db + .select({ + internal_id: products.internal_id, + id: products.id, + name: products.name, + version: products.version, + }) + .from(products) + .where(and(eq(products.org_id, orgId), eq(products.env, env))), + }); + } + static async listFull({ db, orgId, diff --git a/server/src/internal/products/productCacheUtils.ts b/server/src/internal/products/productCacheUtils.ts index fcbba7803..ce2e33ea9 100644 --- a/server/src/internal/products/productCacheUtils.ts +++ b/server/src/internal/products/productCacheUtils.ts @@ -57,6 +57,17 @@ export const buildProductsCacheKey = ({ return `${prefix}:${hash}`; }; +/** Builds the cache key for all product versions (unfiltered, no joins) */ +export const buildAllVersionsProductsCacheKey = ({ + orgId, + env, +}: { + orgId: string; + env: AppEnv; +}) => { + return `${buildProductsCacheKeyPrefix({ orgId, env })}:all_versions`; +}; + /** All possible archived query param values that can be cached */ const ARCHIVED_VARIANTS = [undefined, false, true] as const; @@ -71,13 +82,16 @@ export const invalidateProductsCache = async ({ if (redis.status !== "ready") return; // Build all possible cache keys (deterministic based on archived param variants) - const keysToDelete = ARCHIVED_VARIANTS.map((archived) => - buildProductsCacheKey({ - orgId, - env, - queryParams: archived !== undefined ? { archived } : undefined, - }), - ); + const keysToDelete = [ + ...ARCHIVED_VARIANTS.map((archived) => + buildProductsCacheKey({ + orgId, + env, + queryParams: archived !== undefined ? { archived } : undefined, + }), + ), + buildAllVersionsProductsCacheKey({ orgId, env }), + ]; const regions = getConfiguredRegions(); diff --git a/server/tests/_temp/sync-v2-entity-binding.test.ts b/server/tests/_temp/sync-v2-entity-binding.test.ts new file mode 100644 index 000000000..195c0e55d --- /dev/null +++ b/server/tests/_temp/sync-v2-entity-binding.test.ts @@ -0,0 +1,98 @@ +/** + * TDD repro for syncV2 entity binding bug. + * + * Bug: When the caller passes `phases[].plans[].internal_entity_id` to + * `billing.sync_v2`, the inserted customer product is NOT bound to that + * entity. `initCustomerProduct` sources `internal_entity_id` only from + * `fullCustomer.entity` (the customer's currently-set entity context), + * ignoring the plan's intent. The data DOES flow through SyncPlanInstance → + * SyncProductContext.plan (it's even logged in logSyncContext), but + * `initImmediateSyncCustomerProduct` never threads it down to + * `initFullCustomerProduct`, and `InitFullCustomerProductOptions` has no + * field to receive it. + * + * Red (current): cusProduct.internal_entity_id is null after sync. + * Green (after fix): cusProduct.internal_entity_id === plan.internal_entity_id. + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { createStripeSubscriptionFromProduct } from "@tests/integration/billing/sync/utils/syncTestUtils"; +import { TestFeature } from "@tests/setup/v2Features"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import ctx from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { CusService } from "@/internal/customers/CusService"; +import { EntityService } from "@/internal/api/entities/EntityService"; + +test( + chalk.yellowBright( + "sync-v2 entity binding: plan.internal_entity_id must propagate to inserted customer product", + ), + async () => { + const customerId = "sync-v2-entity-bind"; + + const pro = products.pro({ + id: "sync-v2-entity-pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + s.entities({ count: 1, featureId: TestFeature.Users }), + ], + actions: [], + }); + + const fullCustomerBefore = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const entityList = await EntityService.list({ + db: ctx.db, + internalCustomerId: fullCustomerBefore.internal_id, + }); + expect(entityList.length).toBeGreaterThan(0); + const targetEntity = entityList[0]; + const targetEntityInternalId = targetEntity.internal_id; + + const stripeSubscription = await createStripeSubscriptionFromProduct({ + ctx, + customerId, + productId: pro.id, + }); + expect(stripeSubscription.status).toBe("active"); + + await autumnV1.post("/billing.sync_v2", { + customer_id: customerId, + stripe_subscription_id: stripeSubscription.id, + phases: [ + { + starts_at: "now", + plans: [ + { + plan_id: pro.id, + internal_entity_id: targetEntityInternalId, + }, + ], + }, + ], + }); + + const fullCustomerAfter = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + const cusProduct = fullCustomerAfter.customer_products.find( + (cp) => cp.product_id === pro.id, + ); + expect(cusProduct).toBeDefined(); + expect(cusProduct!.internal_entity_id).toBe(targetEntityInternalId); + }, +); diff --git a/server/tests/clearMasterOrg.ts b/server/tests/clearMasterOrg.ts index 2083a3c5f..84a32d624 100644 --- a/server/tests/clearMasterOrg.ts +++ b/server/tests/clearMasterOrg.ts @@ -12,7 +12,9 @@ import { initDrizzle } from "@/db/initDrizzle.js"; import { logger } from "@/external/logtail/logtailUtils.js"; import { redis } from "@/external/redis/initRedis.js"; import { redisV2 } from "@/external/redis/initRedisV2.js"; +import { getSqsClient } from "@/queue/initSqs.js"; import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js"; +import { PurgeQueueCommand } from "@aws-sdk/client-sqs"; import { OrgService } from "@/internal/orgs/OrgService.js"; import { clearOrg } from "./utils/setup/clearOrg.js"; import { setupOrg } from "./utils/setup/setupOrg.js"; @@ -138,6 +140,20 @@ export const clearMasterOrg = async () => { ); } } + const purgeQueue = async (queueUrl: string | undefined, label: string) => { + if (!queueUrl) return; + try { + const sqs = getSqsClient({ queueUrl }); + await sqs.send(new PurgeQueueCommand({ QueueUrl: queueUrl })); + console.log(chalk.green(`✅ Purged ${label} SQS queue.`)); + } catch (err) { + console.log(chalk.yellow(`⚠️ Skipped ${label} SQS purge: ${err}`)); + } + }; + + await purgeQueue(process.env.SQS_QUEUE_URL_V2, "primary"); + await purgeQueue(process.env.TRACK_SQS_QUEUE_URL, "track"); + console.log(chalk.green("\n✅ Master org setup complete!\n")); } catch (error) { console.error(chalk.red("\n❌ Error:"), error); diff --git a/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts b/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts new file mode 100644 index 000000000..580edc71a --- /dev/null +++ b/server/tests/integration/crud/events/track-unlimited-cusEnt-attribution.test.ts @@ -0,0 +1,204 @@ +/** + * TDD test: when a customer has both an unlimited and a limited cusEnt for the + * same feature, a track event currently short-circuits in + * executePostgresDeductionV2 / executeRedisDeductionV2 (`unlimitedFeatureIds.length > 0`) + * with zero mutation logs. resolveInternalProductIdForEvent then returns null, + * so the persisted event has `internal_product_id = null` and an empty + * `deductions` array — exactly the "No plan" bucket we see in Mintlify's + * analytics for Anthropic. + * + * Red-failure mode (current behavior): + * - event.internal_product_id IS NULL + * - event.deductions IS NULL (or empty) + * - track response `deductions` is empty/undefined + * + * Green-success criteria (after fix): + * - event.internal_product_id === unlimited plan's internal_id + * - event.deductions contains a synthetic entry pointing at the unlimited + * cusEnt with the track call's value (echoed entity_id when present) + * - track response `deductions` contains the same synthetic entry + * - the limited cusEnt's balance is NOT mutated (we only attribute, never + * actually deduct on the unlimited path) + */ + +import { expect, test } from "bun:test"; +import { + type ApiCustomerV5, + 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 { timeout } from "@tests/utils/genUtils.js"; +import { + cleanupOrgRollout, + setOrgRolloutPercent, +} from "@tests/utils/rolloutTestUtils.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { and, desc, eq } from "drizzle-orm"; +import { db } from "@/db/initDrizzle.js"; +import { + customerEntitlements, + events, + products as productsTable, +} from "@autumn/shared"; + +const TINYBIRD_INGEST_WAIT_MS = 3000; + +test( + `${chalk.yellowBright("unlimited-cusent-attribution: track on customer with mixed unlimited+limited entitlements attributes the event to the unlimited plan")}`, + async () => { + const customerId = `unlimited-cusent-attribution-${Date.now()}`; + + // Base plan: limited messages (100/month). + const baseProd = products.base({ + id: "base-limited", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + // Add-on plan: unlimited messages. Anthropic's analogue is enterprise v1 + // with allowance_type=unlimited stacked alongside enterprise v2. + const unlimitedAddon = products.base({ + id: "unlimited-addon", + items: [items.unlimitedMessages()], + isAddOn: true, + }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [baseProd, unlimitedAddon] }), + ], + actions: [ + s.attach({ productId: baseProd.id }), + s.attach({ productId: unlimitedAddon.id }), + ], + }); + + const orgId = ctx.org.id; + + try { + // Force V3 path (executeRedisDeductionV2 → resolveInternalProductIdForEvent). + await setOrgRolloutPercent({ orgId, percent: 100 }); + + // initProductsV0 mutates each product's `id` in-place to suffix it + // with productPrefix (defaults to customerId), so `unlimitedAddon.id` + // is already the final stored id. + const unlimitedPlanId = unlimitedAddon.id; + const unlimitedProductRow = await db + .select({ internal_id: productsTable.internal_id }) + .from(productsTable) + .where( + and( + eq(productsTable.org_id, orgId), + eq(productsTable.env, ctx.env), + eq(productsTable.id, unlimitedPlanId), + ), + ) + .limit(1); + const expectedUnlimitedInternalId = + unlimitedProductRow[0]?.internal_id ?? null; + expect(expectedUnlimitedInternalId).not.toBeNull(); + + // Track usage. Anthropic's calls are AI_CREDITS with value 1..N; we + // use value=7 here so the synthetic deduction is unambiguous. + const TRACK_VALUE = 7; + const trackResponse = (await autumnV2_2.track({ + customer_id: customerId, + feature_id: TestFeature.Messages, + value: TRACK_VALUE, + })) as TrackResponseV3; + + // ---- Synchronous track response assertions ---------------------- + // After the fix, the synthetic mutation log flows through + // projectMutationLogsToTrackDeductionsV2 → response.deductions. + expect(trackResponse.deductions).toBeDefined(); + expect(trackResponse.deductions ?? []).toHaveLength(1); + const responseDeduction = (trackResponse.deductions ?? [])[0]; + expect(responseDeduction?.feature_id).toBe(TestFeature.Messages); + expect(responseDeduction?.plan_id).toBe(unlimitedPlanId); + expect(responseDeduction?.value).toBe(TRACK_VALUE); + + // ---- Persisted event assertions --------------------------------- + // Wait for the event batch flush (globalEventBatchingManager). + await timeout(TINYBIRD_INGEST_WAIT_MS); + + const customer = (await autumnV2_2.customers.get(customerId, { + with_autumn_id: true, + })) as ApiCustomerV5 & { autumn_id?: string }; + const internalCustomerId = customer.autumn_id; + expect(internalCustomerId).toBeDefined(); + + const eventRows = await db + .select({ + id: events.id, + event_name: events.event_name, + value: events.value, + entity_id: events.entity_id, + internal_product_id: events.internal_product_id, + deductions: events.deductions, + }) + .from(events) + .where( + and( + eq(events.org_id, orgId), + eq(events.env, ctx.env), + eq(events.internal_customer_id, internalCustomerId as string), + ), + ) + .orderBy(desc(events.created_at)) + .limit(5); + + expect(eventRows.length).toBeGreaterThan(0); + const latestEvent = eventRows[0]; + + // The fix: the event is attributed to the unlimited plan. + expect(latestEvent.internal_product_id).toBe( + expectedUnlimitedInternalId, + ); + + // The deductions array on the persisted event mirrors the synthetic + // mutation log: one entry with the unlimited plan's id and the + // tracked value. + expect(latestEvent.deductions).not.toBeNull(); + expect(latestEvent.deductions ?? []).toHaveLength(1); + const persistedDeduction = (latestEvent.deductions ?? [])[0]; + expect(persistedDeduction?.feature_id).toBe(TestFeature.Messages); + expect(persistedDeduction?.plan_id).toBe(unlimitedPlanId); + expect(persistedDeduction?.value).toBe(TRACK_VALUE); + + // The track call had no entity_id, so the synthetic mutation log + // should echo null and the persisted event row should have entity_id + // = null. (See "Echo the track calls entity ID" requirement.) + expect(latestEvent.entity_id).toBeNull(); + + // ---- Non-mutation invariant ------------------------------------- + // The synthetic mutation log is in-memory only; no cusEnt row should + // be touched. Query both cusEnt balances directly: the limited + // one stays at 100 (its starting allowance), and the unlimited one + // stays at whatever it started at. + const cusEntRows = await db + .select({ + id: customerEntitlements.id, + balance: customerEntitlements.balance, + unlimited: customerEntitlements.unlimited, + }) + .from(customerEntitlements) + .where( + eq( + customerEntitlements.internal_customer_id, + internalCustomerId as string, + ), + ); + const limitedRow = cusEntRows.find((r) => !r.unlimited); + const unlimitedRow = cusEntRows.find((r) => r.unlimited); + expect(limitedRow?.balance).toBe(100); + expect(unlimitedRow?.unlimited).toBe(true); + } finally { + await cleanupOrgRollout({ orgId }); + } + }, + { timeout: 120_000 }, +); diff --git a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts index 9f360a54d..fb8c6cd36 100644 --- a/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts +++ b/shared/models/billingModels/customerProduct/initFullCustomerProductContext.ts @@ -88,6 +88,9 @@ export interface InitFullCustomerProductOptions { /** When true, preserve subscription_ids even for non-paid-recurring products (used by sync). */ keepSubscriptionIds?: boolean; + /** Override the entity the customer product is bound to. Used by sync to honor `plan.internal_entity_id` instead of falling back to `fullCustomer.entity`. */ + internalEntityId?: string; + previousCustomerProductId?: string; onTrialEnd?: TrialOnEnd; } diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index 36d819228..e26122959 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -96,6 +96,7 @@ export const useMigrationsQuery = () => { limit?: number; only?: string[]; concurrency?: number; + lazy_run?: boolean; }) => { const { data } = await axiosInstance.post<{ migration_id: string; diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index a9abd41f9..c696665ca 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { PageContainer } from "@/components/general/PageContainer"; -import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useFeatureFlags } from "@/hooks/useFeatureFlags"; import { useEnv } from "@/utils/envUtils"; import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide"; @@ -32,6 +31,7 @@ export const AnalyticsView = () => { const [clickHouseDisabled, setClickHouseDisabled] = useState(false); const [hasCleared, setHasCleared] = useState(false); const [groupFilter, setGroupFilter] = useState(null); + const [planDeselected, setPlanDeselected] = useState>(new Set()); const navigate = useNavigate(); const env = useEnv(); @@ -50,61 +50,11 @@ export const AnalyticsView = () => { truncated, entityNames, customerNames, + planNames, totals, eventNames: responseEventNames, } = useAnalyticsData({ hasCleared }); - // Build internal_product_id → display name from the products cache so the - // chart can label plan_id groups (backend ships raw internal ids). - // `/products/products` only returns the latest version per public id, so - // historical versions (e.g. a customer still on v1 after we ship v2) get - // merged in from `customer.customer_products`. When the same public id has - // multiple versions in scope, suffix with ` v{version}`. - const { products } = useProductsQuery({ allVersions: true }); - const planNames = useMemo(() => { - type Entry = { - internal_id: string; - id: string; - name: string; - version: number; - }; - const entries: Entry[] = []; - const seen = new Set(); - const push = (e: Partial & { internal_id?: string | null }) => { - if (!e.internal_id || seen.has(e.internal_id)) return; - seen.add(e.internal_id); - entries.push({ - internal_id: e.internal_id, - id: e.id ?? e.internal_id, - name: e.name ?? e.id ?? e.internal_id, - version: e.version ?? 1, - }); - }; - - for (const p of products) push(p); - for (const cp of customer?.customer_products ?? []) { - push({ - internal_id: cp.product?.internal_id, - id: cp.product?.id, - name: cp.product?.name, - version: cp.product?.version, - }); - } - - // Count public id occurrences so we only suffix when there's ambiguity. - const idCount = new Map(); - for (const e of entries) { - idCount.set(e.id, (idCount.get(e.id) ?? 0) + 1); - } - - const map: Record = {}; - for (const e of entries) { - const showVersion = (idCount.get(e.id) ?? 0) >= 2; - map[e.internal_id] = showVersion ? `${e.name} v${e.version}` : e.name; - } - return map; - }, [products, customer]); - // Show toast when data is truncated due to too many unique group values const hasShownTruncationToast = useRef(false); useEffect(() => { @@ -119,9 +69,9 @@ export const AnalyticsView = () => { } }, [truncated, groupBy]); - // Clear the filter when groupBy changes useEffect(() => { setGroupFilter(null); + setPlanDeselected(new Set()); }, [groupBy]); // Extract unique group values from events data for filtering @@ -165,16 +115,20 @@ export const AnalyticsView = () => { return { chartData: null, chartConfig: null }; } - // Apply frontend filter if a group filter is selected. Use an - // explicit null check because empty string is a meaningful filter - // value for plan_id ("no plan"). let filteredEvents = events; - if (groupBy && groupFilter !== null) { - // Handle special case for column-based operators (not a property) + if (groupBy === "plan_id" && planDeselected.size > 0) { + const filteredData = events.data.filter( + (row: Record) => + !planDeselected.has(String(row.plan_id ?? "")), + ); + filteredEvents = { + ...events, + data: filteredData, + rows: filteredData.length, + }; + } else if (groupBy && groupBy !== "plan_id" && groupFilter !== null) { const groupByColumn = - groupBy === "customer_id" || - groupBy === "entity_id" || - groupBy === "plan_id" + groupBy === "customer_id" || groupBy === "entity_id" ? groupBy : `properties.${groupBy}`; const filteredData = events.data.filter( @@ -206,7 +160,7 @@ export const AnalyticsView = () => { }); return { chartData: transformed, chartConfig: config }; - }, [events, features, groupBy, groupFilter, entityNames, customerNames, planNames]); + }, [events, features, groupBy, groupFilter, planDeselected, entityNames, customerNames, planNames]); // Build legend entries (sorted desc, zero-values filtered). The // width-aware overflow logic lives in ChartLegend. @@ -313,6 +267,8 @@ export const AnalyticsView = () => { propertyKeys, groupFilter, setGroupFilter, + planDeselected, + setPlanDeselected, availableGroupValues, entityNames, customerNames, @@ -356,7 +312,7 @@ export const AnalyticsView = () => { )} - {!chartData && !queryLoading && ( + {(!chartData || chartData.data.length === 0) && !queryLoading && (

No events found. Please widen your filters.{" "} diff --git a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx index 86c36cbfd..7f85fd37e 100644 --- a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx +++ b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx @@ -7,13 +7,16 @@ import { Check } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { cn } from "@/lib/utils"; @@ -34,12 +37,36 @@ export const SelectGroupByDropdown = ({ const { groupFilter, setGroupFilter, + planDeselected, + setPlanDeselected, availableGroupValues, entityNames, customerNames, planNames, } = useAnalyticsContext(); + const togglePlanDeselected = (value: string) => { + setPlanDeselected((prev: Set) => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }); + }; + + const activeDeselectedCount = availableGroupValues.reduce( + (acc: number, v: string) => acc + (planDeselected?.has(v) ? 1 : 0), + 0, + ); + const selectedPlanCount = + availableGroupValues.length - activeDeselectedCount; + const allPlansSelected = activeDeselectedCount === 0; + const handlePlanSelectAll = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setPlanDeselected(new Set()); + }; + const currentGroupBy = searchParams.get("group_by") || ""; const customerId = searchParams.get("customer_id"); const showCustomerIdOption = !customerId; @@ -244,11 +271,78 @@ export const SelectGroupByDropdown = ({ )} - {/* Filter section - only shown when a groupBy is selected */} - {currentGroupBy && availableGroupValues.length > 0 && ( - <> - - + {currentGroupBy === "plan_id" && + availableGroupValues.length > 0 && ( + <> + + + + Filter plans + {!allPlansSelected && ( + + {selectedPlanCount} + + )} + + +

+ +
+ +
+ {availableGroupValues.map((value: string) => { + const displayValue = + value === "AUTUMN_RESERVED" + ? "Other values" + : value === "" + ? "No plan" + : (planNames?.[value] ?? value); + const isChecked = !planDeselected?.has(value); + const wouldDeselectLast = + isChecked && selectedPlanCount === 1; + return ( + { + e.preventDefault(); + if (wouldDeselectLast) return; + togglePlanDeselected(value); + }} + className="flex items-center gap-2 cursor-pointer" + > + + + {displayValue} + + + ); + })} +
+ + + + )} + + {currentGroupBy && + currentGroupBy !== "plan_id" && + availableGroupValues.length > 0 && ( + <> + Filter by value @@ -266,15 +360,11 @@ export const SelectGroupByDropdown = ({ const displayValue = value === "AUTUMN_RESERVED" ? "Other values" - : value === "" && currentGroupBy === "plan_id" - ? "No plan" - : currentGroupBy === "entity_id" - ? (entityNames?.[value] ?? value) - : currentGroupBy === "customer_id" - ? (customerNames?.[value] ?? value) - : currentGroupBy === "plan_id" - ? (planNames?.[value] ?? value) - : value; + : currentGroupBy === "entity_id" + ? (entityNames?.[value] ?? value) + : currentGroupBy === "customer_id" + ? (customerNames?.[value] ?? value) + : value; return ( ); })} - - - )} + + )}
diff --git a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx index e6662fa90..3190bde09 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx @@ -100,6 +100,7 @@ export const useAnalyticsData = ({ truncated: data?.truncated ?? false, entityNames: (data?.entityNames as Record) ?? undefined, customerNames: (data?.customerNames as Record) ?? undefined, + planNames: (data?.planNames as Record) ?? undefined, totals: (data?.totals as | Record diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx index 44c9db333..a687ef7a7 100644 --- a/vite/src/views/migrations/migration-list/MigrationListTable.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -23,12 +23,10 @@ export function MigrationListTable() { }, }); - const hasRows = table.getRowModel().rows.length > 0; - const getRowHref = (row: Migration) => pushPage({ path: `/migrations/${row.id}` }); - if (!isLoading && !hasRows) { + if (!isLoading && migrations.length === 0) { return ( [ diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index de8381518..f380510eb 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -8,6 +8,7 @@ import { CaretDownIcon, CaretLeftIcon, CaretRightIcon, + CheckIcon, EyeIcon, ListMagnifyingGlassIcon, PlayIcon, @@ -23,6 +24,7 @@ import { Table } from "@/components/general/table"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { Dialog, DialogContent, @@ -165,6 +167,13 @@ export function MigrationLiveView({ const [dismissedError, setDismissedError] = useState(null); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [sample, setSample] = useState({ + open: false, + mode: "limit" as "limit" | "select", + limit: "10", + customerIds: [] as string[], + running: null as "dry" | "live" | null, + }); const { cancelRun, isCanceling } = useMigrationsQuery(); const debouncedSetSearch = useMemo( @@ -384,7 +393,7 @@ export function MigrationLiveView({ + + +
+ {sample.mode === "limit" ? ( +
+ + + setSample((s) => ({ + ...s, + limit: e.target.value, + })) + } + placeholder="10" + /> + + + {Math.min( + Number(sample.limit) || 0, + enrichedCustomers.filter((c) => !c._event).length, + )}{" "} + customers + +
+ ) : ( +
+ + + setSample((s) => ({ ...s, customerIds: ids })) + } + /> +
+ )} +
+ + + { + setSample((s) => ({ ...s, running: "dry" })); + if (sample.mode === "limit") { + const topIds = enrichedCustomers + .filter((c) => !c._event) + .slice(0, Number(sample.limit)) + .map((c) => c.id ?? c.internal_id); + await triggerRun({ + dryRun: true, + only: topIds, + }); + } else { + await triggerRun({ + dryRun: true, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Dry Run{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + { + setSample((s) => ({ ...s, running: "live" })); + if (sample.mode === "limit") { + await triggerRun({ + dryRun: false, + limit: Number(sample.limit), + }); + } else { + await triggerRun({ + dryRun: false, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Run Live{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + + +
@@ -596,3 +766,127 @@ export function MigrationLiveView({
); } + +function SampleCustomerPreview({ + customers, + limit, +}: { + customers: CustomerRow[]; + limit: number; +}) { + const unrun = customers.filter((c) => !c._event); + const previewed = unrun.slice(0, limit); + if (limit === 0) return null; + return ( +
+ {previewed.length === 0 ? ( +
+ No customers to preview +
+ ) : ( + previewed.map((c) => ( +
+ + {c.name || c.id || c.internal_id} + + {c.email && ( + + {c.email} + + )} +
+ )) + )} +
+ ); +} + +function SampleCustomerPicker({ + customers, + selectedIds, + onChange, +}: { + customers: CustomerRow[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const [search, setSearch] = useState(""); + const filtered = useMemo(() => { + if (!search) return customers; + const q = search.toLowerCase(); + return customers.filter( + (c) => + c.name?.toLowerCase().includes(q) || + c.id?.toLowerCase().includes(q) || + c.email?.toLowerCase().includes(q), + ); + }, [customers, search]); + + const toggle = (id: string) => { + onChange( + selectedIds.includes(id) + ? selectedIds.filter((v) => v !== id) + : [...selectedIds, id], + ); + }; + + return ( +
+ setSearch(e.target.value)} + placeholder="Search customers..." + className="text-sm" + /> +
+ {filtered.length === 0 ? ( +
+ No customers found +
+ ) : ( + filtered.map((c) => { + const isSelected = selectedIds.includes(c.id ?? c.internal_id); + return ( + + ); + }) + )} +
+ + {selectedIds.length} selected + +
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/OperationsForm.tsx b/vite/src/views/migrations/migration/operations/OperationsForm.tsx index 43beb28a3..361d94faf 100644 --- a/vite/src/views/migrations/migration/operations/OperationsForm.tsx +++ b/vite/src/views/migrations/migration/operations/OperationsForm.tsx @@ -10,6 +10,7 @@ import { CheckIcon, PackageIcon, PencilSimpleIcon, + PlusIcon, } from "@phosphor-icons/react"; import { @@ -19,6 +20,7 @@ import { DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { ActionCard } from "../shared/ActionCard"; +import { DASHED_BUTTON_CLASS } from "../shared/AddButton"; import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks"; import { AddPlansSection } from "./AddPlanOpForm"; import { UpdatePlanOpForm } from "./UpdatePlanOpForm"; @@ -184,45 +186,41 @@ export function OperationsForm({ )} -
- Add Operation -
- - } - heading="Update Plan" - subheading="Modify existing customer plans" - onClick={() => - setOperations([...operations, DEFAULT_UPDATE_PLAN]) - } - className="flex-1" - /> - {!hasAddPlans && ( - - } - heading="Add Plan" - subheading="Assign a new plan to customers" +
+ + + + Add Operation + + + - setOperations([ - ...operations, - { type: "add_plan", plan_id: "" }, - ]) + setOperations([...operations, DEFAULT_UPDATE_PLAN]) } - className="flex-1" - /> - )} -
+ > + + Update Plan + + {!hasAddPlans && ( + + setOperations([ + ...operations, + { type: "add_plan", plan_id: "" }, + ]) + } + > + + Add Plan + + )} + +
)} diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index d2bc70415..596dfb2a6 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -42,18 +42,21 @@ import { import { RemoveItemRows } from "./RemoveItemRows"; function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) { - const { products } = useProductsQuery(); + const { products } = useProductsQuery({ allVersions: true }); - const targetPlanId = - typeof planFilter.plan_id === "string" && planFilter.plan_id - ? planFilter.plan_id - : null; + const targetIds = extractPlanIds(planFilter.plan_id); + const idSet = new Set(targetIds); - const matchingProducts = targetPlanId - ? products.filter((p) => p.id === targetPlanId) - : []; + const matchingProducts = + idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : []; + const seen = new Set(); return matchingProducts + .filter((p) => { + if (seen.has(p.version)) return false; + seen.add(p.version); + return true; + }) .map((p) => ({ value: String(p.version), label: `v${p.version}`,