Merge branch 'main' into fix/sync-entities
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
22
server/src/external/tinybird/initTinybirdV2.ts
vendored
22
server/src/external/tinybird/initTinybirdV2.ts
vendored
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)`, {
|
||||
logger?.info(
|
||||
`Sent ${events.length} events to Tinybird (us-east)`,
|
||||
{
|
||||
data: {
|
||||
region: "secondary",
|
||||
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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
// Invalid JSON — leave null so the caller can distinguish missing
|
||||
// vs explicit empty.
|
||||
return item as unknown as TrackDeduction;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
lastRowMicros = tinybirdTimestampToEpochMicros(row.timestamp);
|
||||
|
||||
@@ -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 {
|
||||
// Invalid JSON — leave null so the caller can distinguish missing
|
||||
// vs explicit empty.
|
||||
return item as unknown as TrackDeduction;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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<string, number> = {
|
||||
"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<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([
|
||||
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<string, string> | 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<string, string> | 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +78,21 @@ 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({
|
||||
@@ -194,6 +218,7 @@ export const prepareFeatureDeductionV2 = ({
|
||||
credit_cost: rollover.credit_cost,
|
||||
})),
|
||||
unlimitedFeatureIds,
|
||||
unlimitedCusEnt,
|
||||
lock: preparedLock,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -68,6 +68,7 @@ export const initImmediateSyncCustomerProduct = ({
|
||||
status: stripeSubscriptionToAutumnStatus({
|
||||
stripeStatus: stripeSubscription.status,
|
||||
}),
|
||||
internalEntityId: plan.internal_entity_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,9 +21,9 @@ export const getByStripeSubId = async ({
|
||||
inStatuses?: string[];
|
||||
}): Promise<FullCusProduct[]> => {
|
||||
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: {
|
||||
|
||||
@@ -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<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({
|
||||
db,
|
||||
orgId,
|
||||
|
||||
@@ -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) =>
|
||||
const keysToDelete = [
|
||||
...ARCHIVED_VARIANTS.map((archived) =>
|
||||
buildProductsCacheKey({
|
||||
orgId,
|
||||
env,
|
||||
queryParams: archived !== undefined ? { archived } : undefined,
|
||||
}),
|
||||
);
|
||||
),
|
||||
buildAllVersionsProductsCacheKey({ orgId, env }),
|
||||
];
|
||||
|
||||
const regions = getConfiguredRegions();
|
||||
|
||||
|
||||
98
server/tests/_temp/sync-v2-entity-binding.test.ts
Normal file
98
server/tests/_temp/sync-v2-entity-binding.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export const useMigrationsQuery = () => {
|
||||
limit?: number;
|
||||
only?: string[];
|
||||
concurrency?: number;
|
||||
lazy_run?: boolean;
|
||||
}) => {
|
||||
const { data } = await axiosInstance.post<{
|
||||
migration_id: string;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [planDeselected, setPlanDeselected] = useState<Set<string>>(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<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
|
||||
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<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 =
|
||||
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 = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!chartData && !queryLoading && (
|
||||
{(!chartData || chartData.data.length === 0) && !queryLoading && (
|
||||
<div className="flex-1 px-10 pt-6">
|
||||
<p className="text-t3 text-sm">
|
||||
No events found. Please widen your filters.{" "}
|
||||
|
||||
@@ -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<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 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 && (
|
||||
<>
|
||||
<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 />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-t4 font-normal">
|
||||
Filter by value
|
||||
</DropdownMenuLabel>
|
||||
@@ -266,14 +360,10 @@ 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;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -291,7 +381,6 @@ export const SelectGroupByDropdown = ({
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,7 @@ export const useAnalyticsData = ({
|
||||
truncated: data?.truncated ?? false,
|
||||
entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
|
||||
customerNames: (data?.customerNames as Record<string, string>) ?? undefined,
|
||||
planNames: (data?.planNames as Record<string, string>) ?? undefined,
|
||||
totals:
|
||||
(data?.totals as
|
||||
| Record<string, { count: number; sum: number }>
|
||||
|
||||
@@ -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 (
|
||||
<EmptyState
|
||||
type="migrations"
|
||||
|
||||
@@ -42,6 +42,7 @@ export function useRealtimeSubscriptions({
|
||||
dry_run: dryRun,
|
||||
limit,
|
||||
only,
|
||||
lazy_run: true,
|
||||
});
|
||||
if (result.trigger_run_id && result.public_access_token) {
|
||||
setSubscriptions((prev) => [
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="default"
|
||||
className="rounded-r-none"
|
||||
className="rounded-r-none border-r-0"
|
||||
onClick={() => setIsRunDialogOpen(true)}
|
||||
isLoading={isRunning}
|
||||
>
|
||||
@@ -407,10 +416,10 @@ export function MigrationLiveView({
|
||||
Dry Run All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => triggerRun({ dryRun: false, limit: 10 })}
|
||||
onClick={() => setSample((s) => ({ ...s, open: true }))}
|
||||
>
|
||||
<PlayIcon size={14} weight="fill" />
|
||||
Sample (10)
|
||||
Run Sample
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -502,6 +511,167 @@ export function MigrationLiveView({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -596,3 +766,127 @@ export function MigrationLiveView({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t mt-3 pt-3 flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-t1">Add Operation</span>
|
||||
<div className="flex gap-3">
|
||||
<ActionCard
|
||||
icon={
|
||||
<PencilSimpleIcon
|
||||
size={20}
|
||||
weight="duotone"
|
||||
className="text-t3 shrink-0"
|
||||
/>
|
||||
}
|
||||
heading="Update Plan"
|
||||
subheading="Modify existing customer plans"
|
||||
<div className="border-t mt-3 pt-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
|
||||
<PlusIcon size={10} />
|
||||
Add Operation
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--anchor-width)"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() =>
|
||||
setOperations([...operations, DEFAULT_UPDATE_PLAN])
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="duotone" />
|
||||
Update Plan
|
||||
</DropdownMenuItem>
|
||||
{!hasAddPlans && (
|
||||
<ActionCard
|
||||
icon={
|
||||
<PackageIcon
|
||||
size={20}
|
||||
weight="duotone"
|
||||
className="text-t3 shrink-0"
|
||||
/>
|
||||
}
|
||||
heading="Add Plan"
|
||||
subheading="Assign a new plan to customers"
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() =>
|
||||
setOperations([
|
||||
...operations,
|
||||
{ type: "add_plan", plan_id: "" },
|
||||
])
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
>
|
||||
<PackageIcon size={14} weight="duotone" />
|
||||
Add Plan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<number>();
|
||||
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}`,
|
||||
|
||||
Reference in New Issue
Block a user