fix: sync entities and added tolerance to hasCustomerProductEnded
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -86,7 +86,6 @@ next-env.d.ts
|
||||
shared/drizzle
|
||||
|
||||
|
||||
run.sh
|
||||
commands.sh
|
||||
env.sh
|
||||
server/env.sh
|
||||
|
||||
@@ -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
25
run.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,7 @@ export const computeSyncFuturePhases = ({
|
||||
fullCustomer,
|
||||
fullProduct: productContext.fullProduct,
|
||||
featureQuantities: productContext.featureQuantities,
|
||||
entity: productContext.entity,
|
||||
startsAt: phaseContext.startsAt,
|
||||
endsAt: phaseContext.endsAt,
|
||||
currentEpochMs,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user