Merge branch 'main' into fix/sync-entities

This commit is contained in:
John Yeo
2026-05-19 12:01:38 +01:00
committed by GitHub
35 changed files with 1105 additions and 216 deletions

View File

@@ -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` | | PostgreSQL | 5432 | Database: `autumn`, user: `postgres`, password: `postgres` |
| Redis Stack | 6379 | Used for `CACHE_URL` and `CACHE_URL_US_EAST` (RedisJSON required) | | Redis Stack | 6379 | Used for `CACHE_URL` and `CACHE_URL_US_EAST` (RedisJSON required) |
| ElasticMQ | 9324 | Local SQS replacement, queue: `autumn.fifo` | | 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 | | Server | 8080 | Autumn API server |
| Vite | 3000 | Frontend dev server | | Vite | 3000 | Frontend dev server |
| Checkout | 3001 | Checkout app dev server | | Checkout | 3001 | Checkout app dev server |

View File

@@ -67,7 +67,7 @@ AWS_ACCESS_KEY_ID=x
AWS_SECRET_ACCESS_KEY=x AWS_SECRET_ACCESS_KEY=x
# ClickHouse (local) # ClickHouse (local)
TINYBIRD_US_EAST_CLICKHOUSE_URL=http://localhost:8123 TINYBIRD_CLICKHOUSE_URL=http://localhost:8123
# App URLs # App URLs
BETTER_AUTH_URL=http://localhost:${serverPort} BETTER_AUTH_URL=http://localhost:${serverPort}

View File

@@ -3,8 +3,8 @@ import { type ClickHouseClient, createClient } from "@clickhouse/client";
// ClickHouse URL is different from API URL // ClickHouse URL is different from API URL
// API: https://api.europe-west2.gcp.tinybird.co // API: https://api.europe-west2.gcp.tinybird.co
// ClickHouse: https://europe-west2.gcp.clickhouse.tinybird.co // ClickHouse: https://europe-west2.gcp.clickhouse.tinybird.co
const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_US_EAST_CLICKHOUSE_URL; const TINYBIRD_CLICKHOUSE_URL = process.env.TINYBIRD_CLICKHOUSE_URL;
const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
// Debug logging // Debug logging
if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) { if (TINYBIRD_CLICKHOUSE_URL && TINYBIRD_TOKEN) {

View File

@@ -1,19 +1,19 @@
import { createTinybirdApi } from "@tinybirdco/sdk"; import { createTinybirdApi } from "@tinybirdco/sdk";
// Dual-write safety net during the us-east cutover. Reads the legacy const TINYBIRD_US_EAST_API_URL = process.env.TINYBIRD_US_EAST_API_URL;
// us-west env vars; delete with the dual-write logic in `sendEvents.ts` const TINYBIRD_US_EAST_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN;
// once us-east is stable.
const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL;
const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
/** Secondary Tinybird API client for dual-write during region cutover. */ /** Secondary Tinybird API client (us-east region) for dual-write during migration. */
export const tinybirdSecondaryApi = export const tinybirdUsEastApi =
TINYBIRD_API_URL && TINYBIRD_TOKEN TINYBIRD_US_EAST_API_URL && TINYBIRD_US_EAST_TOKEN
? createTinybirdApi({ baseUrl: TINYBIRD_API_URL, token: TINYBIRD_TOKEN }) ? createTinybirdApi({
baseUrl: TINYBIRD_US_EAST_API_URL,
token: TINYBIRD_US_EAST_TOKEN,
})
: null; : null;
if (tinybirdSecondaryApi) { if (tinybirdUsEastApi) {
console.log( 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}`,
); );
} }

View File

@@ -3,7 +3,7 @@ import type { EventInsert } from "@autumn/shared";
import * as Sentry from "@sentry/bun"; import * as Sentry from "@sentry/bun";
import type { Logger } from "@/external/logtail/logtailUtils.js"; import type { Logger } from "@/external/logtail/logtailUtils.js";
import { tinybirdIngest } from "../initTinybird.js"; import { tinybirdIngest } from "../initTinybird.js";
import { tinybirdSecondaryApi } from "../initTinybirdV2.js"; import { tinybirdUsEastApi } from "../initTinybirdV2.js";
import { isTinybirdConfigured } from "../tinybirdUtils.js"; import { isTinybirdConfigured } from "../tinybirdUtils.js";
import { mapToTinybirdEvent } from "./mapEvent.js"; import { mapToTinybirdEvent } from "./mapEvent.js";
@@ -35,7 +35,7 @@ export const sendEventsToTinybird = async ({
const tinybirdEvents = events.map(mapToTinybirdEvent); const tinybirdEvents = events.map(mapToTinybirdEvent);
const reportFailure = (error: unknown, region: "primary" | "secondary") => { const reportFailure = (error: unknown, region: "primary" | "us-east") => {
const errorId = generateErrorId(); const errorId = generateErrorId();
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@@ -81,21 +81,24 @@ export const sendEventsToTinybird = async ({
}) })
.catch((error: unknown) => reportFailure(error, "primary")); .catch((error: unknown) => reportFailure(error, "primary"));
const secondaryWrite = tinybirdSecondaryApi const usEastWrite = tinybirdUsEastApi
? tinybirdSecondaryApi ? tinybirdUsEastApi
.ingestBatch("events", tinybirdEvents) .ingestBatch("events", tinybirdEvents)
.then((result) => { .then((result) => {
logger?.info(`Sent ${events.length} events to Tinybird (secondary)`, { logger?.info(
`Sent ${events.length} events to Tinybird (us-east)`,
{
data: { data: {
region: "secondary", region: "us-east",
eventCount: events.length, eventCount: events.length,
successfulRows: result?.successful_rows, successfulRows: result?.successful_rows,
quarantinedRows: result?.quarantined_rows, quarantinedRows: result?.quarantined_rows,
}, },
}); },
);
}) })
.catch((error: unknown) => reportFailure(error, "secondary")) .catch((error: unknown) => reportFailure(error, "us-east"))
: Promise.resolve(); : Promise.resolve();
await Promise.all([primaryWrite, secondaryWrite]); await Promise.all([primaryWrite, usEastWrite]);
}; };

View File

@@ -1,8 +1,8 @@
import { ErrCode, RecaseError } from "@autumn/shared"; import { ErrCode, RecaseError } from "@autumn/shared";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
const TINYBIRD_API_URL = process.env.TINYBIRD_US_EAST_API_URL; const TINYBIRD_API_URL = process.env.TINYBIRD_API_URL;
const TINYBIRD_TOKEN = process.env.TINYBIRD_US_EAST_TOKEN; const TINYBIRD_TOKEN = process.env.TINYBIRD_TOKEN;
export type TinybirdConfig = { export type TinybirdConfig = {
baseUrl: string; baseUrl: string;

View File

@@ -1,3 +1,4 @@
import type { FullCustomer } from "@autumn/shared";
import type Stripe from "stripe"; import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js"; import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { isFirstSubscriptionInvoice } from "@/external/stripe/invoices/utils/classifyStripeInvoice.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 customPaymentMethod: Stripe.PaymentMethod | null = null;
let customer: FullCustomer | null = null;
try { try {
const partialCustomer = await CusService.getByStripeId({ const partialCustomer = await CusService.getByStripeId({
@@ -63,7 +65,7 @@ export const handleMarketplaceInvoiceNotPaid = async ({
throw new Error("Customer not found"); throw new Error("Customer not found");
} }
const customer = await CusService.getFull({ customer = await CusService.getFull({
ctx, ctx,
idOrInternalId: partialCustomer.internal_id, 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({ const product = await ProductService.getFull({
db, db,
orgId: org.id, orgId: org.id,
@@ -145,6 +127,25 @@ export const handleMarketplaceInvoiceNotPaid = async ({
// Continue anyway - we still need to report payment // 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) { if (!customPaymentMethod) {
throw new Error( throw new Error(
"Cannot resolve custom payment method for failed-invoice payment record", "Cannot resolve custom payment method for failed-invoice payment record",

View File

@@ -105,15 +105,24 @@ export const listByCursor = async ({
if (row.deductions) { if (row.deductions) {
try { try {
const parsed = JSON.parse(row.deductions); const parsed = JSON.parse(row.deductions);
if (Array.isArray(parsed)) { // Tinybird's JSON column re-encodes nested-object array items
deductions = parsed as TrackDeduction[]; // as strings; second parse brings them back to TrackDeduction.
} else if (parsed && Array.isArray(parsed.list)) { const rawList = Array.isArray(parsed)
deductions = parsed.list as TrackDeduction[]; ? 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 { } catch {
// Invalid JSON — leave null so the caller can distinguish missing return item as unknown as TrackDeduction;
// vs explicit empty.
} }
});
}
} catch {}
} }
lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp); lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp);

View File

@@ -91,15 +91,24 @@ export const listEvents = async ({
if (row.deductions) { if (row.deductions) {
try { try {
const parsed = JSON.parse(row.deductions); const parsed = JSON.parse(row.deductions);
if (Array.isArray(parsed)) { // Tinybird's JSON column re-encodes nested-object array items
deductions = parsed as TrackDeduction[]; // as strings; second parse brings them back to TrackDeduction.
} else if (parsed && Array.isArray(parsed.list)) { const rawList = Array.isArray(parsed)
deductions = parsed.list as TrackDeduction[]; ? 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 { } catch {
// Invalid JSON — leave null so the caller can distinguish missing return item as unknown as TrackDeduction;
// vs explicit empty.
} }
});
}
} catch {}
} }
return { return {

View File

@@ -14,8 +14,10 @@ import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js";
import { createRoute } from "@/honoMiddlewares/routeHandler.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js"; import { getCustomerNames } from "@/internal/analytics/actions/getCustomerNames.js";
import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js"; import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { CusService } from "@/internal/customers/CusService.js"; import { CusService } from "@/internal/customers/CusService.js";
import { eventActions } from "../actions/eventActions.js"; import { eventActions } from "../actions/eventActions.js";
import { collapsePlanIdGroups } from "./utils/collapsePlanIdGroups.js";
const STANDARD_INTERVAL_DAYS: Record<string, number> = { const STANDARD_INTERVAL_DAYS: Record<string, number> = {
"24h": 1, "24h": 1,
@@ -129,6 +131,39 @@ export const handleInternalAggregateEvents = createRoute({
return { start: aligned.getTime(), end: now.getTime() }; 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<string, string> | undefined;
let planNames: Record<string, string> | 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([ const [{ formatted: events, truncated }, totals] = await Promise.all([
eventActions.aggregate({ eventActions.aggregate({
ctx, ctx,
@@ -140,10 +175,10 @@ export const handleInternalAggregateEvents = createRoute({
event_names, event_names,
bin_size: binSize, bin_size: binSize,
aggregateAll, aggregateAll,
group_by: group_by, group_by: resolvedGroupBy,
customer, customer,
timezone: timezone, timezone: timezone,
max_groups, max_groups: isPlanIdGrouping ? effectiveMaxGroups : max_groups,
}, },
}), }),
eventActions.getCountAndSum({ 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 // When grouping by entity_id, resolve entity names from ClickHouse
let entityNames: Record<string, string> | undefined; let entityNames: Record<string, string> | undefined;
if (group_by === "entity_id" && events?.data) { if (resolvedGroupBy === "entity_id" && events?.data) {
const entityIds = [ const entityIds = [
...new Set( ...new Set(
events.data events.data
@@ -184,7 +223,7 @@ export const handleInternalAggregateEvents = createRoute({
} }
let customerNames: Record<string, string> | undefined; let customerNames: Record<string, string> | undefined;
if (group_by === "customer_id" && events?.data) { if (resolvedGroupBy === "customer_id" && events?.data) {
const customerIds = [ const customerIds = [
...new Set( ...new Set(
events.data events.data
@@ -214,6 +253,7 @@ export const handleInternalAggregateEvents = createRoute({
truncated, truncated,
entityNames, entityNames,
customerNames, customerNames,
planNames,
}); });
}, },
}); });

View File

@@ -0,0 +1,42 @@
import { Decimal } from "decimal.js";
import type { ClickHouseResult } from "@autumn/shared";
export const collapsePlanIdGroups = ({
events,
internalIdToPublicId,
}: {
events: ClickHouseResult;
internalIdToPublicId: Record<string, string>;
}) => {
const collapsed = new Map<string, Record<string, string | number>>();
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;
};

View File

@@ -109,6 +109,7 @@ export const executePostgresDeductionV2 = async ({
rollovers, rollovers,
customerEntitlements, customerEntitlements,
unlimitedFeatureIds, unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock, lock: preparedLock,
} = prepareFeatureDeductionV2({ } = prepareFeatureDeductionV2({
ctx, ctx,
@@ -129,6 +130,25 @@ export const executePostgresDeductionV2 = async ({
redisInstance: ctx.redisV2, 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; continue;
} }

View File

@@ -120,6 +120,7 @@ export const executeRedisDeductionV2 = async ({
rollovers, rollovers,
customerEntitlements, customerEntitlements,
unlimitedFeatureIds, unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock, lock: preparedLock,
} = prepareFeatureDeductionV2({ } = prepareFeatureDeductionV2({
ctx, ctx,
@@ -140,6 +141,25 @@ export const executeRedisDeductionV2 = async ({
redisInstance: redisInstance ?? ctx.redisV2, 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; continue;
} }

View File

@@ -1,5 +1,7 @@
import { import {
AllowanceType,
cusEntToStartingBalance, cusEntToStartingBalance,
type FullCusEntWithFullCusProduct,
type FullSubject, type FullSubject,
fullSubjectToCustomerEntitlements, fullSubjectToCustomerEntitlements,
fullSubjectToOverageAllowedByFeatureId, fullSubjectToOverageAllowedByFeatureId,
@@ -58,6 +60,15 @@ export const prepareFeatureDeductionV2 = ({
}); });
const unlimitedFeatureIds: string[] = []; 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) { for (const relevantFeature of relevantFeatures) {
const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({ const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({
@@ -67,8 +78,21 @@ export const prepareFeatureDeductionV2 = ({
if (featureUnlimited) { if (featureUnlimited) {
unlimitedFeatureIds.push(relevantFeature.id); 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 effectiveFeatureIds = relevantFeatures.map((candidate) => candidate.id);
const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({ const spendLimitByFeatureId = fullSubjectToSpendLimitByFeatureId({
@@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({
credit_cost: rollover.credit_cost, credit_cost: rollover.credit_cost,
})), })),
unlimitedFeatureIds, unlimitedFeatureIds,
unlimitedCusEnt,
lock: preparedLock, lock: preparedLock,
}; };
}; };

View File

@@ -45,6 +45,11 @@ export type PreparedFeatureDeduction = {
// rolloverIds: string[]; // rolloverIds: string[];
rollovers: RolloverDeduction[]; rollovers: RolloverDeduction[];
unlimitedFeatureIds: string[]; 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?: { lock?: {
enabled: true; enabled: true;
lock_id?: string; lock_id?: string;

View File

@@ -84,6 +84,7 @@ export const computeSyncFuturePhases = ({
accessStartsAt: productContext.accessStartsAt, accessStartsAt: productContext.accessStartsAt,
subscriptionId: stripeSubscription?.id, subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSchedule?.id, subscriptionScheduleId: stripeSchedule?.id,
internalEntityId: productContext.plan.internal_entity_id,
}); });
insertCustomerProducts.push(cusProduct); insertCustomerProducts.push(cusProduct);
phaseIds.push(cusProduct.id); phaseIds.push(cusProduct.id);

View File

@@ -68,6 +68,7 @@ export const initImmediateSyncCustomerProduct = ({
status: stripeSubscriptionToAutumnStatus({ status: stripeSubscriptionToAutumnStatus({
stripeStatus: stripeSubscription.status, stripeStatus: stripeSubscription.status,
}), }),
internalEntityId: plan.internal_entity_id,
}, },
}); });
}; };

View File

@@ -41,9 +41,14 @@ export const initCustomerProduct = ({
onTrialEnd, onTrialEnd,
} = initOptions ?? {}; } = initOptions ?? {};
const scopedEntity = entity ?? fullCustomer.entity; const internalEntityId =
const internalEntityId = scopedEntity?.internal_id; initOptions?.internalEntityId ?? fullCustomer.entity?.internal_id;
const entityId = scopedEntity?.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 startsAt = initOptions?.startsAt ?? now;
const endedAt = initOptions?.endedAt; const endedAt = initOptions?.endedAt;

View File

@@ -31,6 +31,7 @@ export const initScheduledCustomerProduct = ({
externalId, externalId,
subscriptionId, subscriptionId,
subscriptionScheduleId, subscriptionScheduleId,
internalEntityId,
}: { }: {
ctx: AutumnContext; ctx: AutumnContext;
fullCustomer: FullCustomer; fullCustomer: FullCustomer;
@@ -48,6 +49,7 @@ export const initScheduledCustomerProduct = ({
* Stripe linkage and downstream actions (cancel, restore) can find it. */ * Stripe linkage and downstream actions (cancel, restore) can find it. */
subscriptionId?: string; subscriptionId?: string;
subscriptionScheduleId?: string; subscriptionScheduleId?: string;
internalEntityId?: string;
}): FullCusProduct => { }): FullCusProduct => {
const startsAtSecondsPrecision = truncateMsToSecondPrecision(startsAt); const startsAtSecondsPrecision = truncateMsToSecondPrecision(startsAt);
const endsAtSecondsPrecision = const endsAtSecondsPrecision =
@@ -75,6 +77,7 @@ export const initScheduledCustomerProduct = ({
externalId, externalId,
subscriptionId, subscriptionId,
subscriptionScheduleId, subscriptionScheduleId,
internalEntityId,
}, },
}); });
}; };

View File

@@ -21,9 +21,9 @@ export const getByStripeSubId = async ({
inStatuses?: string[]; inStatuses?: string[];
}): Promise<FullCusProduct[]> => { }): Promise<FullCusProduct[]> => {
const data = await db.query.customerProducts.findMany({ const data = await db.query.customerProducts.findMany({
where: (_table, { and: dAnd, or: dOr, inArray: dInArray }) => where: (_table, { and: dAnd, inArray: dInArray }) =>
dAnd( dAnd(
dOr(arrayContains(customerProducts.subscription_ids, [stripeSubId])), arrayContains(customerProducts.subscription_ids, [stripeSubId]),
inStatuses ? dInArray(customerProducts.status, inStatuses) : undefined, inStatuses ? dInArray(customerProducts.status, inStatuses) : undefined,
), ),
with: { with: {

View File

@@ -28,7 +28,11 @@ import {
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import type { Logger } from "@/external/logtail/logtailUtils"; import type { Logger } from "@/external/logtail/logtailUtils";
import { queryWithCache } from "@/utils/cacheUtils/queryWithCache"; 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 { getLatestProducts, isFreeProduct } from "./productUtils";
import { sortFullProducts } from "./productUtils/sortProductUtils"; 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<Array<{ internal_id: string; id: string; name: string; version: number }>> {
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({ static async listFull({
db, db,
orgId, orgId,

View File

@@ -57,6 +57,17 @@ export const buildProductsCacheKey = ({
return `${prefix}:${hash}`; 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 */ /** All possible archived query param values that can be cached */
const ARCHIVED_VARIANTS = [undefined, false, true] as const; const ARCHIVED_VARIANTS = [undefined, false, true] as const;
@@ -71,13 +82,16 @@ export const invalidateProductsCache = async ({
if (redis.status !== "ready") return; if (redis.status !== "ready") return;
// Build all possible cache keys (deterministic based on archived param variants) // Build all possible cache keys (deterministic based on archived param variants)
const keysToDelete = ARCHIVED_VARIANTS.map((archived) => const keysToDelete = [
...ARCHIVED_VARIANTS.map((archived) =>
buildProductsCacheKey({ buildProductsCacheKey({
orgId, orgId,
env, env,
queryParams: archived !== undefined ? { archived } : undefined, queryParams: archived !== undefined ? { archived } : undefined,
}), }),
); ),
buildAllVersionsProductsCacheKey({ orgId, env }),
];
const regions = getConfiguredRegions(); const regions = getConfiguredRegions();

View File

@@ -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);
},
);

View File

@@ -12,7 +12,9 @@ import { initDrizzle } from "@/db/initDrizzle.js";
import { logger } from "@/external/logtail/logtailUtils.js"; import { logger } from "@/external/logtail/logtailUtils.js";
import { redis } from "@/external/redis/initRedis.js"; import { redis } from "@/external/redis/initRedis.js";
import { redisV2 } from "@/external/redis/initRedisV2.js"; import { redisV2 } from "@/external/redis/initRedisV2.js";
import { getSqsClient } from "@/queue/initSqs.js";
import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js"; import { deletePlatformSubOrg } from "@/internal/orgs/deleteOrg/deletePlatformSubOrg.js";
import { PurgeQueueCommand } from "@aws-sdk/client-sqs";
import { OrgService } from "@/internal/orgs/OrgService.js"; import { OrgService } from "@/internal/orgs/OrgService.js";
import { clearOrg } from "./utils/setup/clearOrg.js"; import { clearOrg } from "./utils/setup/clearOrg.js";
import { setupOrg } from "./utils/setup/setupOrg.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")); console.log(chalk.green("\n✅ Master org setup complete!\n"));
} catch (error) { } catch (error) {
console.error(chalk.red("\n❌ Error:"), error); console.error(chalk.red("\n❌ Error:"), error);

View File

@@ -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 },
);

View File

@@ -88,6 +88,9 @@ export interface InitFullCustomerProductOptions {
/** When true, preserve subscription_ids even for non-paid-recurring products (used by sync). */ /** When true, preserve subscription_ids even for non-paid-recurring products (used by sync). */
keepSubscriptionIds?: boolean; 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; previousCustomerProductId?: string;
onTrialEnd?: TrialOnEnd; onTrialEnd?: TrialOnEnd;
} }

View File

@@ -96,6 +96,7 @@ export const useMigrationsQuery = () => {
limit?: number; limit?: number;
only?: string[]; only?: string[];
concurrency?: number; concurrency?: number;
lazy_run?: boolean;
}) => { }) => {
const { data } = await axiosInstance.post<{ const { data } = await axiosInstance.post<{
migration_id: string; migration_id: string;

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router"; import { useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageContainer } from "@/components/general/PageContainer"; import { PageContainer } from "@/components/general/PageContainer";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeatureFlags } from "@/hooks/useFeatureFlags"; import { useFeatureFlags } from "@/hooks/useFeatureFlags";
import { useEnv } from "@/utils/envUtils"; import { useEnv } from "@/utils/envUtils";
import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide"; import { OnboardingGuide } from "@/views/onboarding4/OnboardingGuide";
@@ -32,6 +31,7 @@ export const AnalyticsView = () => {
const [clickHouseDisabled, setClickHouseDisabled] = useState(false); const [clickHouseDisabled, setClickHouseDisabled] = useState(false);
const [hasCleared, setHasCleared] = useState(false); const [hasCleared, setHasCleared] = useState(false);
const [groupFilter, setGroupFilter] = useState<string | null>(null); const [groupFilter, setGroupFilter] = useState<string | null>(null);
const [planDeselected, setPlanDeselected] = useState<Set<string>>(new Set());
const navigate = useNavigate(); const navigate = useNavigate();
const env = useEnv(); const env = useEnv();
@@ -50,61 +50,11 @@ export const AnalyticsView = () => {
truncated, truncated,
entityNames, entityNames,
customerNames, customerNames,
planNames,
totals, totals,
eventNames: responseEventNames, eventNames: responseEventNames,
} = useAnalyticsData({ hasCleared }); } = 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<string>();
const push = (e: Partial<Entry> & { 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<string, number>();
for (const e of entries) {
idCount.set(e.id, (idCount.get(e.id) ?? 0) + 1);
}
const map: Record<string, string> = {};
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 // Show toast when data is truncated due to too many unique group values
const hasShownTruncationToast = useRef(false); const hasShownTruncationToast = useRef(false);
useEffect(() => { useEffect(() => {
@@ -119,9 +69,9 @@ export const AnalyticsView = () => {
} }
}, [truncated, groupBy]); }, [truncated, groupBy]);
// Clear the filter when groupBy changes
useEffect(() => { useEffect(() => {
setGroupFilter(null); setGroupFilter(null);
setPlanDeselected(new Set());
}, [groupBy]); }, [groupBy]);
// Extract unique group values from events data for filtering // Extract unique group values from events data for filtering
@@ -165,16 +115,20 @@ export const AnalyticsView = () => {
return { chartData: null, chartConfig: null }; 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; let filteredEvents = events;
if (groupBy && groupFilter !== null) { if (groupBy === "plan_id" && planDeselected.size > 0) {
// Handle special case for column-based operators (not a property) const filteredData = events.data.filter(
(row: Record<string, string | number>) =>
!planDeselected.has(String(row.plan_id ?? "")),
);
filteredEvents = {
...events,
data: filteredData,
rows: filteredData.length,
};
} else if (groupBy && groupBy !== "plan_id" && groupFilter !== null) {
const groupByColumn = const groupByColumn =
groupBy === "customer_id" || groupBy === "customer_id" || groupBy === "entity_id"
groupBy === "entity_id" ||
groupBy === "plan_id"
? groupBy ? groupBy
: `properties.${groupBy}`; : `properties.${groupBy}`;
const filteredData = events.data.filter( const filteredData = events.data.filter(
@@ -206,7 +160,7 @@ export const AnalyticsView = () => {
}); });
return { chartData: transformed, chartConfig: config }; 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 // Build legend entries (sorted desc, zero-values filtered). The
// width-aware overflow logic lives in ChartLegend. // width-aware overflow logic lives in ChartLegend.
@@ -313,6 +267,8 @@ export const AnalyticsView = () => {
propertyKeys, propertyKeys,
groupFilter, groupFilter,
setGroupFilter, setGroupFilter,
planDeselected,
setPlanDeselected,
availableGroupValues, availableGroupValues,
entityNames, entityNames,
customerNames, customerNames,
@@ -356,7 +312,7 @@ export const AnalyticsView = () => {
</div> </div>
)} )}
{!chartData && !queryLoading && ( {(!chartData || chartData.data.length === 0) && !queryLoading && (
<div className="flex-1 px-10 pt-6"> <div className="flex-1 px-10 pt-6">
<p className="text-t3 text-sm"> <p className="text-t3 text-sm">
No events found. Please widen your filters.{" "} No events found. Please widen your filters.{" "}

View File

@@ -7,13 +7,16 @@ import { Check } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router"; import { useLocation, useNavigate, useSearchParams } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton"; import { IconButton } from "@/components/v2/buttons/IconButton";
import { Checkbox } from "@/components/v2/checkboxes/Checkbox";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu"; } from "@/components/v2/dropdowns/DropdownMenu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -34,12 +37,36 @@ export const SelectGroupByDropdown = ({
const { const {
groupFilter, groupFilter,
setGroupFilter, setGroupFilter,
planDeselected,
setPlanDeselected,
availableGroupValues, availableGroupValues,
entityNames, entityNames,
customerNames, customerNames,
planNames, planNames,
} = useAnalyticsContext(); } = useAnalyticsContext();
const togglePlanDeselected = (value: string) => {
setPlanDeselected((prev: Set<string>) => {
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 currentGroupBy = searchParams.get("group_by") || "";
const customerId = searchParams.get("customer_id"); const customerId = searchParams.get("customer_id");
const showCustomerIdOption = !customerId; const showCustomerIdOption = !customerId;
@@ -244,11 +271,78 @@ export const SelectGroupByDropdown = ({
</> </>
)} )}
{/* Filter section - only shown when a groupBy is selected */} {currentGroupBy === "plan_id" &&
{currentGroupBy && availableGroupValues.length > 0 && ( availableGroupValues.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer">
<span className="text-xs">Filter plans</span>
{!allPlansSelected && (
<span className="text-xs text-t3 bg-muted px-1 py-0 rounded-md">
{selectedPlanCount}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-56 max-w-none w-max !overflow-x-visible">
<div className="flex items-center justify-between px-2 h-6">
<button
type="button"
onClick={handlePlanSelectAll}
className={cn(
"px-1 h-5 flex items-center gap-1 text-t2 text-xs hover:text-t1 bg-accent cursor-pointer rounded-md",
allPlansSelected &&
"bg-primary/10 text-primary hover:text-primary/80",
)}
>
Select all
</button>
</div>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto overflow-x-visible">
{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 (
<DropdownMenuItem
key={value}
closeOnClick={false}
disabled={wouldDeselectLast}
onClick={(e) => {
e.preventDefault();
if (wouldDeselectLast) return;
togglePlanDeselected(value);
}}
className="flex items-center gap-2 cursor-pointer"
>
<Checkbox
checked={isChecked}
className="border-border"
/>
<span className="text-xs whitespace-nowrap">
{displayValue}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
{currentGroupBy &&
currentGroupBy !== "plan_id" &&
availableGroupValues.length > 0 && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-t4 font-normal"> <DropdownMenuLabel className="text-xs text-t4 font-normal">
Filter by value Filter by value
</DropdownMenuLabel> </DropdownMenuLabel>
@@ -266,14 +360,10 @@ export const SelectGroupByDropdown = ({
const displayValue = const displayValue =
value === "AUTUMN_RESERVED" value === "AUTUMN_RESERVED"
? "Other values" ? "Other values"
: value === "" && currentGroupBy === "plan_id"
? "No plan"
: currentGroupBy === "entity_id" : currentGroupBy === "entity_id"
? (entityNames?.[value] ?? value) ? (entityNames?.[value] ?? value)
: currentGroupBy === "customer_id" : currentGroupBy === "customer_id"
? (customerNames?.[value] ?? value) ? (customerNames?.[value] ?? value)
: currentGroupBy === "plan_id"
? (planNames?.[value] ?? value)
: value; : value;
return ( return (
<DropdownMenuItem <DropdownMenuItem
@@ -291,7 +381,6 @@ export const SelectGroupByDropdown = ({
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
</DropdownMenuGroup>
</> </>
)} )}
</div> </div>

View File

@@ -100,6 +100,7 @@ export const useAnalyticsData = ({
truncated: data?.truncated ?? false, truncated: data?.truncated ?? false,
entityNames: (data?.entityNames as Record<string, string>) ?? undefined, entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
customerNames: (data?.customerNames as Record<string, string>) ?? undefined, customerNames: (data?.customerNames as Record<string, string>) ?? undefined,
planNames: (data?.planNames as Record<string, string>) ?? undefined,
totals: totals:
(data?.totals as (data?.totals as
| Record<string, { count: number; sum: number }> | Record<string, { count: number; sum: number }>

View File

@@ -23,12 +23,10 @@ export function MigrationListTable() {
}, },
}); });
const hasRows = table.getRowModel().rows.length > 0;
const getRowHref = (row: Migration) => const getRowHref = (row: Migration) =>
pushPage({ path: `/migrations/${row.id}` }); pushPage({ path: `/migrations/${row.id}` });
if (!isLoading && !hasRows) { if (!isLoading && migrations.length === 0) {
return ( return (
<EmptyState <EmptyState
type="migrations" type="migrations"

View File

@@ -42,6 +42,7 @@ export function useRealtimeSubscriptions({
dry_run: dryRun, dry_run: dryRun,
limit, limit,
only, only,
lazy_run: true,
}); });
if (result.trigger_run_id && result.public_access_token) { if (result.trigger_run_id && result.public_access_token) {
setSubscriptions((prev) => [ setSubscriptions((prev) => [

View File

@@ -8,6 +8,7 @@ import {
CaretDownIcon, CaretDownIcon,
CaretLeftIcon, CaretLeftIcon,
CaretRightIcon, CaretRightIcon,
CheckIcon,
EyeIcon, EyeIcon,
ListMagnifyingGlassIcon, ListMagnifyingGlassIcon,
PlayIcon, PlayIcon,
@@ -23,6 +24,7 @@ import { Table } from "@/components/general/table";
import { Badge } from "@/components/v2/badges/Badge"; import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button"; import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton"; import { IconButton } from "@/components/v2/buttons/IconButton";
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -165,6 +167,13 @@ export function MigrationLiveView({
const [dismissedError, setDismissedError] = useState<string | null>(null); const [dismissedError, setDismissedError] = useState<string | null>(null);
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
const [isCancelDialogOpen, setIsCancelDialogOpen] = 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 { cancelRun, isCanceling } = useMigrationsQuery();
const debouncedSetSearch = useMemo( const debouncedSetSearch = useMemo(
@@ -384,7 +393,7 @@ export function MigrationLiveView({
<Button <Button
variant="primary" variant="primary"
size="default" size="default"
className="rounded-r-none" className="rounded-r-none border-r-0"
onClick={() => setIsRunDialogOpen(true)} onClick={() => setIsRunDialogOpen(true)}
isLoading={isRunning} isLoading={isRunning}
> >
@@ -407,10 +416,10 @@ export function MigrationLiveView({
Dry Run All Dry Run All
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => triggerRun({ dryRun: false, limit: 10 })} onClick={() => setSample((s) => ({ ...s, open: true }))}
> >
<PlayIcon size={14} weight="fill" /> <PlayIcon size={14} weight="fill" />
Sample (10) Run Sample
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -502,6 +511,167 @@ export function MigrationLiveView({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={sample.open}
onOpenChange={(open) => setSample((s) => ({ ...s, open }))}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Run Sample</DialogTitle>
<DialogDescription>
Run the migration on a subset of customers.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex border-b border-border">
<button
type="button"
onClick={() =>
setSample((s) => ({ ...s, mode: "limit" }))
}
className={cn(
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
sample.mode === "limit"
? "border-primary text-t1"
: "border-transparent text-t3 hover:text-t2",
)}
>
By count
</button>
<button
type="button"
onClick={() =>
setSample((s) => ({ ...s, mode: "select" }))
}
className={cn(
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
sample.mode === "select"
? "border-primary text-t1"
: "border-transparent text-t3 hover:text-t2",
)}
>
Select customers
</button>
</div>
<div className="min-h-[220px]">
{sample.mode === "limit" ? (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-t3">
Number of customers
</label>
<Input
type="number"
min={1}
value={sample.limit}
onChange={(e) =>
setSample((s) => ({
...s,
limit: e.target.value,
}))
}
placeholder="10"
/>
<SampleCustomerPreview
customers={enrichedCustomers}
limit={Number(sample.limit) || 0}
/>
<span className="text-xs text-t3">
{Math.min(
Number(sample.limit) || 0,
enrichedCustomers.filter((c) => !c._event).length,
)}{" "}
customers
</span>
</div>
) : (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-t3">
Select customers to run
</label>
<SampleCustomerPicker
customers={enrichedCustomers}
selectedIds={sample.customerIds}
onChange={(ids) =>
setSample((s) => ({ ...s, customerIds: ids }))
}
/>
</div>
)}
</div>
</div>
<DialogFooter className="sm:flex-col gap-2">
<ShortcutButton
className="w-full"
variant="secondary"
isLoading={sample.running === "dry"}
disabled={
sample.running !== null ||
(sample.mode === "limit"
? !sample.limit || Number(sample.limit) < 1
: sample.customerIds.length === 0)
}
onClick={async () => {
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 }));
}}
>
<EyeIcon size={14} />
Dry Run{" "}
{sample.mode === "limit"
? `(${sample.limit || 0})`
: `(${sample.customerIds.length})`}
</ShortcutButton>
<ShortcutButton
className="w-full"
metaShortcut="enter"
isLoading={sample.running === "live"}
disabled={
sample.running !== null ||
(sample.mode === "limit"
? !sample.limit || Number(sample.limit) < 1
: sample.customerIds.length === 0)
}
onClick={async () => {
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 }));
}}
>
<PlayIcon size={14} weight="fill" />
Run Live{" "}
{sample.mode === "limit"
? `(${sample.limit || 0})`
: `(${sample.customerIds.length})`}
</ShortcutButton>
</DialogFooter>
</DialogContent>
</Dialog>
</StepIndicator> </StepIndicator>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -596,3 +766,127 @@ export function MigrationLiveView({
</div> </div>
); );
} }
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 (
<div className="h-48 overflow-y-auto rounded-xl border border-border mt-1.5">
{previewed.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-t4">
No customers to preview
</div>
) : (
previewed.map((c) => (
<div
key={c.internal_id}
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm"
>
<span className="flex-1 truncate text-t1">
{c.name || c.id || c.internal_id}
</span>
{c.email && (
<span className="text-xs text-t4 truncate max-w-32">
{c.email}
</span>
)}
</div>
))
)}
</div>
);
}
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 (
<div className="flex flex-col gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search customers..."
className="text-sm"
/>
<div className="h-48 overflow-y-auto rounded-xl border border-border">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-t4">
No customers found
</div>
) : (
filtered.map((c) => {
const isSelected = selectedIds.includes(c.id ?? c.internal_id);
return (
<button
key={c.internal_id}
type="button"
onClick={() => toggle(c.id ?? c.internal_id)}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-muted/50 cursor-pointer transition-colors",
isSelected && "bg-primary/5",
)}
>
<div
className={cn(
"size-4 rounded border flex items-center justify-center shrink-0",
isSelected
? "border-primary bg-primary"
: "border-border",
)}
>
{isSelected && (
<CheckIcon size={10} className="text-white" />
)}
</div>
<span className="flex-1 truncate text-t1">
{c.name || c.id || c.internal_id}
</span>
{c.email && (
<span className="text-xs text-t4 truncate max-w-32">
{c.email}
</span>
)}
</button>
);
})
)}
</div>
<span className="text-xs text-t3">
{selectedIds.length} selected
</span>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
CheckIcon, CheckIcon,
PackageIcon, PackageIcon,
PencilSimpleIcon, PencilSimpleIcon,
PlusIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { import {
@@ -19,6 +20,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/v2/dropdowns/DropdownMenu"; } from "@/components/v2/dropdowns/DropdownMenu";
import { ActionCard } from "../shared/ActionCard"; import { ActionCard } from "../shared/ActionCard";
import { DASHED_BUTTON_CLASS } from "../shared/AddButton";
import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks"; import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks";
import { AddPlansSection } from "./AddPlanOpForm"; import { AddPlansSection } from "./AddPlanOpForm";
import { UpdatePlanOpForm } from "./UpdatePlanOpForm"; import { UpdatePlanOpForm } from "./UpdatePlanOpForm";
@@ -184,45 +186,41 @@ export function OperationsForm({
</> </>
)} )}
<div className="border-t mt-3 pt-3 flex flex-col gap-2"> <div className="border-t mt-3 pt-3">
<span className="text-sm font-medium text-t1">Add Operation</span> <DropdownMenu>
<div className="flex gap-3"> <DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
<ActionCard <PlusIcon size={10} />
icon={ Add Operation
<PencilSimpleIcon </DropdownMenuTrigger>
size={20} <DropdownMenuContent
weight="duotone" align="start"
className="text-t3 shrink-0" className="w-(--anchor-width)"
/> >
} <DropdownMenuItem
heading="Update Plan" closeOnClick
subheading="Modify existing customer plans"
onClick={() => onClick={() =>
setOperations([...operations, DEFAULT_UPDATE_PLAN]) setOperations([...operations, DEFAULT_UPDATE_PLAN])
} }
className="flex-1" >
/> <PencilSimpleIcon size={14} weight="duotone" />
Update Plan
</DropdownMenuItem>
{!hasAddPlans && ( {!hasAddPlans && (
<ActionCard <DropdownMenuItem
icon={ closeOnClick
<PackageIcon
size={20}
weight="duotone"
className="text-t3 shrink-0"
/>
}
heading="Add Plan"
subheading="Assign a new plan to customers"
onClick={() => onClick={() =>
setOperations([ setOperations([
...operations, ...operations,
{ type: "add_plan", plan_id: "" }, { type: "add_plan", plan_id: "" },
]) ])
} }
className="flex-1" >
/> <PackageIcon size={14} weight="duotone" />
Add Plan
</DropdownMenuItem>
)} )}
</div> </DropdownMenuContent>
</DropdownMenu>
</div> </div>
</> </>
)} )}

View File

@@ -42,18 +42,21 @@ import {
import { RemoveItemRows } from "./RemoveItemRows"; import { RemoveItemRows } from "./RemoveItemRows";
function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) { function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) {
const { products } = useProductsQuery(); const { products } = useProductsQuery({ allVersions: true });
const targetPlanId = const targetIds = extractPlanIds(planFilter.plan_id);
typeof planFilter.plan_id === "string" && planFilter.plan_id const idSet = new Set(targetIds);
? planFilter.plan_id
: null;
const matchingProducts = targetPlanId const matchingProducts =
? products.filter((p) => p.id === targetPlanId) idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : [];
: [];
const seen = new Set<number>();
return matchingProducts return matchingProducts
.filter((p) => {
if (seen.has(p.version)) return false;
seen.add(p.version);
return true;
})
.map((p) => ({ .map((p) => ({
value: String(p.version), value: String(p.version),
label: `v${p.version}`, label: `v${p.version}`,