fix: sync entities and added tolerance to hasCustomerProductEnded

This commit is contained in:
johnyeo
2026-05-19 11:42:40 +01:00
parent 6389dd1b64
commit ea922307e4
23 changed files with 813 additions and 772 deletions

1
.gitignore vendored
View File

@@ -86,7 +86,6 @@ next-env.d.ts
shared/drizzle
run.sh
commands.sh
env.sh
server/env.sh

View File

@@ -130,6 +130,7 @@
"docs": "bun -F @autumn/docs dev",
"docs:build": "bun -F @autumn/docs build",
"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout",
"kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done",
"atmn:build": "bun -F atmn build",
"openapi:ts": "bun -F @autumn/openapi ts",
"js:ts": "bun -F autumn-js ts",

25
run.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Root dispatcher: routes file paths under server/ to server/run.sh.
set -e
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
filename="$1"
if [[ -z "$filename" ]]; then
echo "usage: $0 <file> [args...]" >&2
exit 1
fi
# Resolve to an absolute path so the prefix check works for relative inputs too.
if [[ "$filename" = /* ]]; then
resolved="$filename"
else
resolved="$(cd "$(dirname "$filename")" 2>/dev/null && pwd)/$(basename "$filename")"
fi
if [[ "$resolved" == "$repo_root/server/"* ]]; then
exec "$repo_root/server/run.sh" "$resolved" "${@:2}"
fi
echo "no router for: $resolved" >&2
exit 1

View File

@@ -1,12 +1,21 @@
#!/bin/bash
# Run current file
filename="$1"
line="$2"
if [[ "$filename" == *"shell"* ]]; then
"$filename" "${@:2}"
elif [[ "$filename" == *".test.ts" ]]; then
# Test files: use bun test (preload configured in bunfig.toml)
NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -n "$line" && "$line" =~ ^[0-9]+$ ]]; then
# Targeted: run only the test enclosing the given line.
test_name="$(bun "$repo_root/scripts/testScripts/getDescribeAtCursor.ts" "$filename" "$line")"
NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename" -t "$test_name"
else
# Test files: use bun test (preload configured in bunfig.toml)
NODE_ENV=development infisical run --env=dev --recursive -- bun test --timeout 0 "$filename"
fi
elif [[ "$filename" == *".sh"* ]]; then
"$filename"
else

View File

@@ -77,6 +77,7 @@ export const computeSyncFuturePhases = ({
fullCustomer,
fullProduct: productContext.fullProduct,
featureQuantities: productContext.featureQuantities,
entity: productContext.entity,
startsAt: phaseContext.startsAt,
endsAt: phaseContext.endsAt,
currentEpochMs,

View File

@@ -35,7 +35,7 @@ export const initImmediateSyncCustomerProduct = ({
stripeSubscription: Stripe.Subscription;
currentEpochMs: number;
}): FullCusProduct => {
const { plan, fullProduct, featureQuantities } = productContext;
const { plan, fullProduct, featureQuantities, entity } = productContext;
const trialEndsAt = getTrialEndsAtFromStripe({ stripeSubscription });
const { canceledAt, endedAt } = getCancelFieldsFromStripe({
@@ -49,6 +49,7 @@ export const initImmediateSyncCustomerProduct = ({
fullCustomer,
fullProduct,
featureQuantities,
entity,
resetCycleAnchor: resetCycleAnchorMs,
now: currentEpochMs,
freeTrial: null,

View File

@@ -72,8 +72,8 @@ export const logSyncContext = ({
const expire = pc.currentCustomerProduct
? ` expire=${pc.currentCustomerProduct.id}`
: "";
const entity = pc.plan.internal_entity_id
? ` entity=${pc.plan.internal_entity_id}`
const entity = pc.entity
? ` entity=${pc.entity.internal_id}`
: "";
return `${pc.fullProduct.id}${customize}${expire}${entity}`;
})

View File

@@ -1,4 +1,6 @@
import {
type Entity,
EntityNotFoundError,
ErrCode,
type FullCusProduct,
type FullCustomer,
@@ -41,6 +43,23 @@ const fetchStripeSchedule = async ({
return stripeCli.subscriptionSchedules.retrieve(stripeScheduleId);
};
const resolvePlanEntity = ({
plan,
fullCustomer,
}: {
plan: SyncPlanInstance;
fullCustomer: FullCustomer;
}): Entity | undefined => {
if (!plan.entity_id) return undefined;
const entity = fullCustomer.entities.find(
(e) => e.id === plan.entity_id || e.internal_id === plan.entity_id,
);
if (!entity) {
throw new EntityNotFoundError({ entityId: plan.entity_id });
}
return entity;
};
const buildProductContext = async ({
ctx,
fullCustomer,
@@ -67,6 +86,8 @@ const buildProductContext = async ({
initializeUndefinedQuantities: true,
});
const entity = resolvePlanEntity({ plan, fullCustomer });
let currentCustomerProduct: FullCusProduct | undefined;
if ((isImmediate || plan.enable_plan_immediately) && plan.expire_previous) {
const transition = setupAttachTransitionContext({
@@ -82,6 +103,7 @@ const buildProductContext = async ({
customPrices,
customEntitlements,
featureQuantities,
entity,
currentCustomerProduct,
accessStartsAt,
};

View File

@@ -26,6 +26,7 @@ export const initCustomerProduct = ({
freeTrial,
trialEndsAt,
now,
entity,
} = initContext;
const {
subscriptionId,
@@ -40,8 +41,9 @@ export const initCustomerProduct = ({
onTrialEnd,
} = initOptions ?? {};
const internalEntityId = fullCustomer.entity?.internal_id;
const entityId = fullCustomer.entity?.id;
const scopedEntity = entity ?? fullCustomer.entity;
const internalEntityId = scopedEntity?.internal_id;
const entityId = scopedEntity?.id;
const startsAt = initOptions?.startsAt ?? now;
const endedAt = initOptions?.endedAt;

View File

@@ -1,10 +1,12 @@
import {
BillingVersion,
CusProductStatus,
type Entity,
type FeatureOptions,
type FullCusProduct,
type FullCustomer,
type FullProduct,
truncateMsToSecondPrecision,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { initFullCustomerProduct } from "./initFullCustomerProduct";
@@ -21,6 +23,7 @@ export const initScheduledCustomerProduct = ({
fullCustomer,
fullProduct,
featureQuantities,
entity,
startsAt,
endsAt,
currentEpochMs,
@@ -33,6 +36,7 @@ export const initScheduledCustomerProduct = ({
fullCustomer: FullCustomer;
fullProduct: FullProduct;
featureQuantities: FeatureOptions[];
entity?: Entity;
startsAt: number;
endsAt: number | null | undefined;
currentEpochMs: number;
@@ -45,20 +49,27 @@ export const initScheduledCustomerProduct = ({
subscriptionId?: string;
subscriptionScheduleId?: string;
}): FullCusProduct => {
const startsAtSecondsPrecision = truncateMsToSecondPrecision(startsAt);
const endsAtSecondsPrecision =
endsAt === null || endsAt === undefined
? undefined
: truncateMsToSecondPrecision(endsAt);
return initFullCustomerProduct({
ctx,
initContext: {
fullCustomer,
fullProduct,
featureQuantities,
resetCycleAnchor: startsAt,
entity,
resetCycleAnchor: startsAtSecondsPrecision,
freeTrial: null,
now: currentEpochMs,
billingVersion: BillingVersion.V2,
},
initOptions: {
startsAt,
endedAt: endsAt ?? undefined,
startsAt: startsAtSecondsPrecision,
endedAt: endsAtSecondsPrecision,
status: accessStartsAt === undefined ? CusProductStatus.Scheduled : undefined,
accessStartsAt,
externalId,

View File

@@ -0,0 +1,575 @@
import { expect, test } from "bun:test";
import {
CusProductStatus,
customerEntitlements,
customerProducts,
ms,
schedulePhases,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import {
getCustomerProductEntitlementBalances,
getCustomerProductPriceAmounts,
getRequiredScheduleId,
} from "./utils/createScheduleTestHelpers";
test.concurrent(`${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => {
const prepaidMessages = products.base({
id: "prepaid",
items: [items.prepaidMessages()],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-options",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [prepaidMessages] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: prepaidMessages.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
},
],
},
],
});
const insertedProducts = await ctx.db
.select({
options: customerProducts.options,
})
.from(customerProducts)
.where(
eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!),
);
expect(insertedProducts).toHaveLength(1);
expect(insertedProducts[0]!.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 4,
}),
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => {
const base = products.base({
id: "custom-base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: base.id,
customize: {
items: [itemsV2.monthlyWords({ included: 250 })],
},
},
],
},
],
});
const insertedEntitlements = await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(
eq(
customerEntitlements.customer_product_id,
response.phases[0]!.customer_product_ids[0]!,
),
);
expect(insertedEntitlements).toEqual([
{
feature_id: TestFeature.Words,
balance: 250,
},
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, async () => {
const base = products.base({
id: "custom-future-base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize-future",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = Date.now();
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{
plan_id: base.id,
customize: {
items: [itemsV2.monthlyWords({ included: 250 })],
},
},
],
},
],
});
const futurePhase = response.phases[1];
expect(futurePhase).toBeDefined();
const futureCustomerProductId = futurePhase?.customer_product_ids[0];
expect(futureCustomerProductId).toBeTruthy();
if (!futureCustomerProductId) {
throw new Error(
"Expected a scheduled customer product for the future phase",
);
}
const insertedEntitlements = await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(
eq(customerEntitlements.customer_product_id, futureCustomerProductId),
);
expect(insertedEntitlements).toEqual([
{
feature_id: TestFeature.Words,
balance: 250,
},
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, async () => {
const base = products.base({
id: "create-schedule-customize-rollover",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx, testClockId, advancedTo } =
await initScenario({
customerId: "create-schedule-customize-rollover",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = advancedTo;
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
items: [
itemsV2.monthlyWords({ included: 250 }),
itemsV2.dashboard(),
],
},
},
],
},
],
});
const futureCustomerProductId = response.phases[1]!.customer_product_ids[0]!;
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([35]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: now + ms.days(16),
waitForSeconds: 30,
});
const activatedProduct = await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, futureCustomerProductId),
});
expect(activatedProduct?.status).toBe(CusProductStatus.Active);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([35]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, async () => {
const base = products.base({
id: "create-schedule-custom-update",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx, testClockId, advancedTo } =
await initScenario({
customerId: "create-schedule-custom-update",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = advancedTo;
const initialResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
items: [
itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }),
],
},
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 200,
},
],
},
],
},
],
});
const initialFutureCustomerProductId =
initialResponse.phases[1]!.customer_product_ids[0]!;
const updatedResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 55 }),
items: [
itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }),
],
},
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 500,
},
],
},
],
},
],
});
const updatedFutureCustomerProductId =
updatedResponse.phases[1]!.customer_product_ids[0]!;
expect(updatedFutureCustomerProductId).not.toBe(
initialFutureCustomerProductId,
);
expect(
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, initialFutureCustomerProductId),
}),
).toBeUndefined();
const updatedFutureCustomerProduct =
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, updatedFutureCustomerProductId),
});
expect(updatedFutureCustomerProduct?.status).toBe(CusProductStatus.Scheduled);
expect(updatedFutureCustomerProduct?.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 5,
}),
]);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([55]);
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: now + ms.days(16),
waitForSeconds: 30,
});
const activatedFutureCustomerProduct =
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, updatedFutureCustomerProductId),
});
expect(activatedFutureCustomerProduct?.status).toBe(CusProductStatus.Active);
expect(activatedFutureCustomerProduct?.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 5,
}),
]);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([55]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, async () => {
const base = products.base({
id: "base",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-update-with-customize",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = Date.now();
const initialResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
],
});
expect(initialResponse.phases).toHaveLength(1);
const updatedResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 50 }),
items: [itemsV2.monthlyWords({ included: 200 })],
},
},
],
},
],
});
expect(updatedResponse.phases).toHaveLength(2);
const updatedDbPhases = await ctx.db
.select()
.from(schedulePhases)
.where(
eq(
schedulePhases.schedule_id,
getRequiredScheduleId(updatedResponse.schedule_id),
),
);
expect(updatedDbPhases).toHaveLength(2);
const futureCustomerProductId =
updatedResponse.phases[1]!.customer_product_ids[0]!;
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([50]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, async () => {
const base = products.base({
id: "bool-base",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize-boolean",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: base.id,
customize: {
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.dashboard(),
],
},
},
],
},
],
});
const customerProductId = response.phases[0]!.customer_product_ids[0]!;
const entitlementBalances = await getCustomerProductEntitlementBalances({
ctx,
customerProductId,
});
expect(entitlementBalances).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Messages, balance: 100 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
const customerProduct = await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, customerProductId),
});
expect(customerProduct?.is_custom).toBe(true);
});

View File

@@ -1,30 +1,26 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
type CreateScheduleParamsV0Input,
CheckoutAction,
CusProductStatus,
CustomerExpand,
customerEntitlements,
customerPrices,
customerProducts,
ms,
prices,
schedulePhases,
schedules,
type ApiCustomerV3,
type CreateScheduleParamsV0Input,
CheckoutAction,
CusProductStatus,
customerProducts,
ms,
schedulePhases,
schedules,
} from "@autumn/shared";
import {
confirmAutumnCheckout,
fetchAutumnCheckout,
confirmAutumnCheckout,
fetchAutumnCheckout,
} from "@tests/integration/billing/utils/checkout/autumnCheckoutUtils";
import { isAutumnCheckoutUrl } from "@tests/integration/billing/utils/isAutumnCheckoutUrl";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { timeout } from "@tests/utils/genUtils.js";
@@ -33,89 +29,15 @@ import chalk from "chalk";
import { and, eq, inArray } from "drizzle-orm";
import { CusService } from "@/internal/customers/CusService";
import {
getFullCustomerSchedule,
hydrateCustomerWithSchedules,
getFullCustomerSchedule,
hydrateCustomerWithSchedules,
} from "@/internal/customers/cusUtils/getFullCustomerSchedule";
import { attachPaymentMethod } from "@/utils/scriptUtils/initCustomer";
const getCustomerProductRows = async ({
ctx,
customerId,
productIds,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
customerId: string;
productIds: string[];
}) =>
await ctx.db
.select({
productId: customerProducts.product_id,
status: customerProducts.status,
})
.from(customerProducts)
.where(
and(
eq(customerProducts.customer_id, customerId),
inArray(customerProducts.product_id, productIds),
),
);
const getCustomerProductPriceAmounts = async ({
ctx,
customerProductId,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
customerProductId: string;
}) =>
(
await ctx.db
.select({ config: prices.config })
.from(customerPrices)
.innerJoin(prices, eq(customerPrices.price_id, prices.id))
.where(eq(customerPrices.customer_product_id, customerProductId))
)
.map((row) =>
row.config && "amount" in row.config ? row.config.amount : undefined,
)
.filter((amount): amount is number => typeof amount === "number")
.sort((a, b) => a - b);
const getCustomerProductEntitlementBalances = async ({
ctx,
customerProductId,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
customerProductId: string;
}) =>
await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(eq(customerEntitlements.customer_product_id, customerProductId));
const getRequiredScheduleId = (scheduleId: string | null) => {
if (!scheduleId) {
throw new Error("Expected create_schedule response to include schedule_id");
}
return scheduleId;
};
const getCheckoutId = (paymentUrl: string | null | undefined) => {
if (!paymentUrl) {
throw new Error("Expected create_schedule response to include payment_url");
}
const checkoutId = paymentUrl.split("/c/")[1];
if (!checkoutId) {
throw new Error(`Expected Autumn checkout URL, received: ${paymentUrl}`);
}
return checkoutId;
};
import {
getCheckoutId,
getCustomerProductRows,
getRequiredScheduleId,
} from "./utils/createScheduleTestHelpers";
test.concurrent(`${chalk.yellowBright("create-schedule: bills the first phase immediately and stores later phases as scheduled")}`, async () => {
const pro = products.pro({
@@ -330,416 +252,6 @@ test.concurrent(`${chalk.yellowBright("create-schedule: copies entity_id and rep
expect(newCustomerProducts[0]!.status).toBe(CusProductStatus.Active);
});
test.concurrent(`${chalk.yellowBright("create-schedule: preserves feature quantity options on created customer products")}`, async () => {
const prepaidMessages = products.base({
id: "prepaid",
items: [items.prepaidMessages()],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-options",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [prepaidMessages] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: prepaidMessages.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
},
],
},
],
});
const insertedProducts = await ctx.db
.select({
options: customerProducts.options,
})
.from(customerProducts)
.where(
eq(customerProducts.id, response.phases[0]!.customer_product_ids[0]!),
);
expect(insertedProducts).toHaveLength(1);
expect(insertedProducts[0]!.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 4,
}),
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on created customer products")}`, async () => {
const base = products.base({
id: "custom-base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: base.id,
customize: {
items: [itemsV2.monthlyWords({ included: 250 })],
},
},
],
},
],
});
const insertedEntitlements = await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(
eq(
customerEntitlements.customer_product_id,
response.phases[0]!.customer_product_ids[0]!,
),
);
expect(insertedEntitlements).toEqual([
{
feature_id: TestFeature.Words,
balance: 250,
},
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: preserves customize.items on future scheduled customer products")}`, async () => {
const base = products.base({
id: "custom-future-base",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize-future",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = Date.now();
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{
plan_id: base.id,
customize: {
items: [itemsV2.monthlyWords({ included: 250 })],
},
},
],
},
],
});
const futurePhase = response.phases[1];
expect(futurePhase).toBeDefined();
const futureCustomerProductId = futurePhase?.customer_product_ids[0];
expect(futureCustomerProductId).toBeTruthy();
if (!futureCustomerProductId) {
throw new Error(
"Expected a scheduled customer product for the future phase",
);
}
const insertedEntitlements = await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(
eq(customerEntitlements.customer_product_id, futureCustomerProductId),
);
expect(insertedEntitlements).toEqual([
{
feature_id: TestFeature.Words,
balance: 250,
},
]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: customized future phases keep custom prices and entitlements through activation")}`, async () => {
const base = products.base({
id: "create-schedule-customize-rollover",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx, testClockId, advancedTo } =
await initScenario({
customerId: "create-schedule-customize-rollover",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = advancedTo;
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
items: [
itemsV2.monthlyWords({ included: 250 }),
itemsV2.dashboard(),
],
},
},
],
},
],
});
const futureCustomerProductId = response.phases[1]!.customer_product_ids[0]!;
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([35]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: now + ms.days(16),
waitForSeconds: 30,
});
const activatedProduct = await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, futureCustomerProductId),
});
expect(activatedProduct?.status).toBe(CusProductStatus.Active);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([35]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Words, balance: 250 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: updating a future customized phase replaces its custom prices and quantities before activation")}`, async () => {
const base = products.base({
id: "create-schedule-custom-update",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx, testClockId, advancedTo } =
await initScenario({
customerId: "create-schedule-custom-update",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = advancedTo;
const initialResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
items: [
itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }),
],
},
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 200,
},
],
},
],
},
],
});
const initialFutureCustomerProductId =
initialResponse.phases[1]!.customer_product_ids[0]!;
const updatedResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(15),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 55 }),
items: [
itemsV2.prepaidMessages({ amount: 20, billingUnits: 100 }),
],
},
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 500,
},
],
},
],
},
],
});
const updatedFutureCustomerProductId =
updatedResponse.phases[1]!.customer_product_ids[0]!;
expect(updatedFutureCustomerProductId).not.toBe(
initialFutureCustomerProductId,
);
expect(
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, initialFutureCustomerProductId),
}),
).toBeUndefined();
const updatedFutureCustomerProduct =
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, updatedFutureCustomerProductId),
});
expect(updatedFutureCustomerProduct?.status).toBe(CusProductStatus.Scheduled);
expect(updatedFutureCustomerProduct?.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 5,
}),
]);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([55]);
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: now + ms.days(16),
waitForSeconds: 30,
});
const activatedFutureCustomerProduct =
await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, updatedFutureCustomerProductId),
});
expect(activatedFutureCustomerProduct?.status).toBe(CusProductStatus.Active);
expect(activatedFutureCustomerProduct?.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 5,
}),
]);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: updatedFutureCustomerProductId,
}),
).toEqual([55]);
});
test.concurrent(`${chalk.yellowBright("create-schedule: persists the new schedule and returns required_action when immediate billing is deferred")}`, async () => {
const pro = products.pro({
id: "deferred-pro",
@@ -1237,42 +749,12 @@ test.concurrent(`${chalk.yellowBright("create-schedule: plans omitted from the n
waitForSeconds: 30,
});
const productRows = await getCustomerProductRows({
ctx,
customerId,
productIds: [nowBase.id, nowAddon.id, nextBase.id, nextAddon.id],
});
expect(
productRows
.filter((productRow) => productRow.status === CusProductStatus.Active)
.sort((a, b) => a.productId!.localeCompare(b.productId!)),
).toEqual(
[
{ productId: nextAddon.id, status: CusProductStatus.Active },
{ productId: nextBase.id, status: CusProductStatus.Active },
].sort((a, b) => a.productId.localeCompare(b.productId)),
);
expect(
productRows.filter(
(productRow) => productRow.status === CusProductStatus.Scheduled,
),
).toHaveLength(0);
expect(
productRows
.filter((productRow) => productRow.status === CusProductStatus.Expired)
.sort((a, b) => a.productId!.localeCompare(b.productId!)),
).toEqual(
[
{ productId: nowAddon.id, status: CusProductStatus.Expired },
{ productId: nowBase.id, status: CusProductStatus.Expired },
].sort((a, b) => a.productId.localeCompare(b.productId)),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.products?.map((product) => product.id).sort()).toEqual(
[nextAddon.id, nextBase.id].sort(),
);
await expectCustomerProducts({
customer,
active: [nextAddon.id, nextBase.id],
notPresent: [nowAddon.id, nowBase.id],
});
});
test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a schedule after earlier phases started when past phases are resubmitted")}`, async () => {
@@ -1308,7 +790,7 @@ test.concurrent(`${chalk.yellowBright("create-schedule: rejects updating a sched
});
const now = advancedTo;
const initialResponse = await autumnV1.billing.createSchedule({
await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
@@ -1979,9 +1461,9 @@ test.concurrent(`${chalk.yellowBright("create-schedule: hydrates schedules on th
expect(hydratedCustomer.schedule?.customer_id).toBe(customerId);
expect(hydratedCustomer.schedule?.phases).toHaveLength(2);
expect(hydratedCustomer.schedule?.phases[0]?.starts_at).toBe(now);
expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe(
now + ms.days(30),
);
expect(hydratedCustomer.schedule?.phases[1]?.starts_at).toBe(
now + ms.days(30),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: adding a future phase to an existing single-phase schedule persists both phases")}`, async () => {
@@ -2085,150 +1567,6 @@ test.concurrent(`${chalk.yellowBright("create-schedule: adding a future phase to
expect(futureProducts[0]!.product_id).toBe(premium.id);
});
test.concurrent(`${chalk.yellowBright("create-schedule: updating a schedule with customized future phase persists both phases and custom items")}`, async () => {
const base = products.base({
id: "base",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-update-with-customize",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const now = Date.now();
const initialResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
],
});
expect(initialResponse.phases).toHaveLength(1);
const updatedResponse = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: base.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{
plan_id: base.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 50 }),
items: [itemsV2.monthlyWords({ included: 200 })],
},
},
],
},
],
});
expect(updatedResponse.phases).toHaveLength(2);
const updatedDbPhases = await ctx.db
.select()
.from(schedulePhases)
.where(
eq(
schedulePhases.schedule_id,
getRequiredScheduleId(updatedResponse.schedule_id),
),
);
expect(updatedDbPhases).toHaveLength(2);
const futureCustomerProductId =
updatedResponse.phases[1]!.customer_product_ids[0]!;
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual([50]);
expect(
await getCustomerProductEntitlementBalances({
ctx,
customerProductId: futureCustomerProductId,
}),
).toEqual(
expect.arrayContaining([{ feature_id: TestFeature.Words, balance: 200 }]),
);
});
test.concurrent(`${chalk.yellowBright("create-schedule: customize with boolean feature persists the boolean entitlement")}`, async () => {
const base = products.base({
id: "bool-base",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice(),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "create-schedule-customize-boolean",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [base] }),
],
actions: [],
});
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: Date.now(),
plans: [
{
plan_id: base.id,
customize: {
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.dashboard(),
],
},
},
],
},
],
});
const customerProductId = response.phases[0]!.customer_product_ids[0]!;
const entitlementBalances = await getCustomerProductEntitlementBalances({
ctx,
customerProductId,
});
expect(entitlementBalances).toEqual(
expect.arrayContaining([
{ feature_id: TestFeature.Messages, balance: 100 },
{ feature_id: TestFeature.Dashboard, balance: 0 },
]),
);
const customerProduct = await ctx.db.query.customerProducts.findFirst({
where: eq(customerProducts.id, customerProductId),
});
expect(customerProduct?.is_custom).toBe(true);
});
test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entity-level schedules coexist independently")}`, async () => {
const pro = products.pro({
id: "pro",
@@ -2259,6 +1597,10 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entit
starts_at: now,
plans: [{ plan_id: pro.id }],
},
{
starts_at: now + ms.days(30),
plans: [{ plan_id: pro.id }],
},
],
});
@@ -2270,6 +1612,10 @@ test.concurrent(`${chalk.yellowBright("create-schedule: customer-level and entit
starts_at: now,
plans: [{ plan_id: addon.id }],
},
{
starts_at: now + ms.days(30),
plans: [{ plan_id: addon.id }],
},
],
});

View File

@@ -0,0 +1,89 @@
import {
customerEntitlements,
customerPrices,
customerProducts,
prices,
} from "@autumn/shared";
import type { initScenario } from "@tests/utils/testInitUtils/initScenario";
import { and, eq, inArray } from "drizzle-orm";
type Ctx = Awaited<ReturnType<typeof initScenario>>["ctx"];
export const getCustomerProductRows = async ({
ctx,
customerId,
productIds,
}: {
ctx: Ctx;
customerId: string;
productIds: string[];
}) =>
await ctx.db
.select({
productId: customerProducts.product_id,
status: customerProducts.status,
})
.from(customerProducts)
.where(
and(
eq(customerProducts.customer_id, customerId),
inArray(customerProducts.product_id, productIds),
),
);
export const getCustomerProductPriceAmounts = async ({
ctx,
customerProductId,
}: {
ctx: Ctx;
customerProductId: string;
}) =>
(
await ctx.db
.select({ config: prices.config })
.from(customerPrices)
.innerJoin(prices, eq(customerPrices.price_id, prices.id))
.where(eq(customerPrices.customer_product_id, customerProductId))
)
.map((row) =>
row.config && "amount" in row.config ? row.config.amount : undefined,
)
.filter((amount): amount is number => typeof amount === "number")
.sort((a, b) => a - b);
export const getCustomerProductEntitlementBalances = async ({
ctx,
customerProductId,
}: {
ctx: Ctx;
customerProductId: string;
}) =>
await ctx.db
.select({
feature_id: customerEntitlements.feature_id,
balance: customerEntitlements.balance,
})
.from(customerEntitlements)
.where(eq(customerEntitlements.customer_product_id, customerProductId));
export const getRequiredScheduleId = (scheduleId: string | null) => {
if (!scheduleId) {
throw new Error("Expected create_schedule response to include schedule_id");
}
return scheduleId;
};
export const getCheckoutId = (paymentUrl: string | null | undefined) => {
if (!paymentUrl) {
throw new Error("Expected create_schedule response to include payment_url");
}
const checkoutId = paymentUrl.split("/c/")[1];
if (!checkoutId) {
throw new Error(`Expected Autumn checkout URL, received: ${paymentUrl}`);
}
return checkoutId;
};

View File

@@ -4,7 +4,7 @@ import type { SyncParamsV1 } from "@autumn/shared";
export type ExpectedPlan = {
plan_id: string;
quantity?: number;
internal_entity_id?: string;
entity_id?: string;
expire_previous?: boolean;
customize?:
| null
@@ -43,8 +43,8 @@ const expectPlanCorrect = ({
if (expected.quantity !== undefined) {
expect(plan.quantity ?? 1).toBe(expected.quantity);
}
if (expected.internal_entity_id !== undefined) {
expect(plan.internal_entity_id).toBe(expected.internal_entity_id);
if (expected.entity_id !== undefined) {
expect(plan.entity_id).toBe(expected.entity_id);
}
if (expected.expire_previous !== undefined) {
expect(plan.expire_previous).toBe(expected.expire_previous);

View File

@@ -10,10 +10,9 @@ export const SyncPlanInstanceSchema = MultiPlanInstanceSchema.extend({
description:
"Number of customer product instances to create from this plan entry. Defaults to 1. Used to express add-ons with quantity > 1.",
}),
internal_entity_id: z.string().optional().meta({
entity_id: z.string().optional().meta({
description:
"If set, the resulting customer product is bound to this entity.",
internal: true,
"If set, the resulting customer product is bound to this entity. Resolved against the customer's entities; pass the public entity id.",
}),
expire_previous: z.boolean().optional().meta({
description:

View File

@@ -6,6 +6,7 @@ import type {
FullCusProduct,
} from "../../cusProductModels/cusProductModels";
import type { FullCustomer } from "../../cusModels/fullCusModel";
import type { Entity } from "../../cusModels/entityModels/entityModels";
import type { Entitlement } from "../../productModels/entModels/entModels";
import type { Price } from "../../productModels/priceModels/priceModels";
import type { FullProduct } from "../../productModels/productModels";
@@ -16,6 +17,8 @@ export interface SyncProductContext {
customPrices: Price[];
customEntitlements: Entitlement[];
featureQuantities: FeatureOptions[];
/** Resolved per-plan entity scope (from plan.entity_id), if any. */
entity?: Entity;
/** Existing active cusProduct in the same product group, if `expire_previous` was set. */
currentCustomerProduct?: FullCusProduct;
accessStartsAt?: number;

View File

@@ -5,6 +5,7 @@ import type {
TrialOnEnd,
} from "@models/productModels/freeTrialModels/freeTrialModels";
import type { ApiVersion } from "../../../api/versionUtils/ApiVersion";
import type { Entity } from "../../cusModels/entityModels/entityModels";
import type { FullCustomer } from "../../cusModels/fullCusModel";
import type {
CollectionMethod,
@@ -31,6 +32,13 @@ export interface InitFullCustomerProductContext {
fullProduct: FullProduct;
featureQuantities: FeatureOptions[];
/**
* Per-call override for the entity the resulting cusProduct should bind to.
* Wins over `fullCustomer.entity`. Used by flows (e.g. sync) where the
* entity is plan-specific rather than request-wide.
*/
entity?: Entity;
// For customer entitlements
resetCycleAnchor: number | "now"; // Unix timestamp of the next
// existingUsages?: ExistingUsages;

View File

@@ -14,6 +14,7 @@ import {
isFreeProduct,
isOneOffProduct,
} from "../../productUtils/classifyProduct/classifyProductUtils";
import { ms } from "../../common";
import { notNullish, nullish } from "../../utils";
import { ACTIVE_STATUSES, RELEVANT_STATUSES } from "..";
import { cusProductToPrices } from "../convertCusProduct";
@@ -111,22 +112,16 @@ export const isCustomerProductExpired = (cp?: FullCusProduct) => {
return cp.status === CusProductStatus.Expired;
};
/**
* Checks if a canceling customer product has reached its end time.
* Uses an optional tolerance to handle timing differences between Stripe and webhook arrival.
*
* @param toleranceMs - Tolerance in milliseconds (default: 10 minutes)
*/
export const hasCustomerProductEnded = (
cp: FullCusProduct,
params?: { nowMs?: number },
params?: { nowMs?: number; toleranceMs?: number },
) => {
const nowMs = params?.nowMs ?? Date.now();
const toleranceMs = params?.toleranceMs ?? ms.seconds(1);
const hasEnded =
// isCustomerProductCanceling(cp) &&
notNullish(cp.ended_at) && nowMs >= cp.ended_at;
notNullish(cp.ended_at) && nowMs + toleranceMs >= cp.ended_at;
return hasEnded;
};

View File

@@ -9,7 +9,6 @@ import {
import { parseAsString, useQueryStates } from "nuqs";
import { useEffect, useMemo, useState } from "react";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { withEffectiveCustomerProductStatus } from "@/views/customers2/utils/effectiveCustomerProductStatus";
// Hook to sync entity_id between query params and store
export const useEntity = () => {
@@ -47,19 +46,14 @@ export const useEntity = () => {
// Hook to get a customer product and its productV2 by customer product ID
export const useSubscriptionById = ({ itemId }: { itemId: string | null }) => {
const { customer, testClockFrozenTimeMs } = useCusQuery();
const { customer } = useCusQuery();
const cusProduct = useMemo(() => {
if (!itemId || !customer?.customer_products) return null;
const customerProduct = customer.customer_products.find(
return customer.customer_products.find(
(p: FullCusProduct) => p.id === itemId,
);
if (!customerProduct) return null;
return withEffectiveCustomerProductStatus({
customerProduct,
nowMs: testClockFrozenTimeMs,
});
}, [itemId, customer?.customer_products, testClockFrozenTimeMs]);
) ?? null;
}, [itemId, customer?.customer_products]);
const productV2 = useMemo(() => {
if (!cusProduct) return null;

View File

@@ -133,7 +133,7 @@ export function SyncPlanRow({
const availableProducts = products.filter((p) => !p.archived);
const selectedProduct = products.find((p) => p.id === plan.plan_id);
const hasCustomize = Boolean(plan.customize);
const hasEntityScope = Boolean(plan.internal_entity_id);
const hasEntityScope = Boolean(plan.entity_id);
const [scopeOpen, setScopeOpen] = useState<boolean>(hasEntityScope);
@@ -279,9 +279,9 @@ export function SyncPlanRow({
{entities.length > 0 && scopeOpen && (
<EntityScopeSubRow
entities={entities}
scopeEntityId={plan.internal_entity_id}
scopeEntityId={plan.entity_id}
onChange={(entityId) =>
onChange({ ...plan, internal_entity_id: entityId })
onChange({ ...plan, entity_id: entityId })
}
/>
)}

View File

@@ -14,7 +14,6 @@ import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable";
import { withEffectiveCustomerProductStatus } from "@/views/customers2/utils/effectiveCustomerProductStatus";
import { CustomerBalanceTable } from "../customer-balance/CustomerBalanceTable";
import { EmptyState } from "../EmptyState";
import { CustomerFeatureUsageColumns } from "./CustomerFeatureUsageColumns";
@@ -29,7 +28,7 @@ import {
} from "./customerFeatureUsageUtils";
export function CustomerFeatureUsageTable() {
const { customer, features, isLoading, testClockFrozenTimeMs } = useCusQuery();
const { customer, features, isLoading } = useCusQuery();
const { setSheet } = useSheetStore();
const { entityId } = useEntity();
@@ -44,13 +43,7 @@ export function CustomerFeatureUsageTable() {
}, [customer?.entities, entityId]);
const filteredCustomerProducts = useMemo(() => {
const customerProducts = (customer?.customer_products ?? []).map(
(customerProduct) =>
withEffectiveCustomerProductStatus({
customerProduct,
nowMs: testClockFrozenTimeMs,
}),
);
const customerProducts = customer?.customer_products ?? [];
if (!selectedEntity) {
return customerProducts;
@@ -62,7 +55,7 @@ export function CustomerFeatureUsageTable() {
cp.internal_entity_id === selectedEntity.internal_id ||
cp.entity_id === selectedEntity.id,
);
}, [customer?.customer_products, selectedEntity, testClockFrozenTimeMs]);
}, [customer?.customer_products, selectedEntity]);
const cusEnts = useMemo((): FullCusEntWithFullCusProduct[] => {
const productEnts = flattenCustomerEntitlements({

View File

@@ -4,7 +4,6 @@ import { useMemo } from "react";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
import { filterCustomerProductsByType } from "../components/table/customer-products/customerProductsTableFilters";
import { withEffectiveCustomerProductStatus } from "../utils/effectiveCustomerProductStatus";
function filterBySelectedEntity({
products,
@@ -37,26 +36,14 @@ export function useCustomerProductsData() {
"customerProductsShowExpired",
parseAsBoolean.withDefault(false),
);
const customerWithEffectiveStatuses = useMemo(
() => ({
...customer,
customer_products: customer.customer_products.map((customerProduct) =>
withEffectiveCustomerProductStatus({
customerProduct,
nowMs: testClockFrozenTimeMs,
}),
),
}),
[customer, testClockFrozenTimeMs],
);
const { subscriptions, purchases } = useMemo(
() =>
filterCustomerProductsByType({
customer: customerWithEffectiveStatuses,
customer,
showExpired: showExpired ?? false,
}),
[customerWithEffectiveStatuses, showExpired],
[customer, showExpired],
);
// Filter entity-level products by selected entity (if any)

View File

@@ -1,19 +0,0 @@
import {
CusProductStatus,
type FullCusProduct,
hasCustomerProductEnded,
} from "@autumn/shared";
export function withEffectiveCustomerProductStatus({
customerProduct,
nowMs,
}: {
customerProduct: FullCusProduct;
nowMs?: number;
}): FullCusProduct {
if (!hasCustomerProductEnded(customerProduct, { nowMs })) {
return customerProduct;
}
return { ...customerProduct, status: CusProductStatus.Expired };
}