chore: cleanup & use accessStartsAt

This commit is contained in:
Charlie Lamb
2026-05-06 13:29:53 +01:00
parent d0f3e774c8
commit ccbb03371c
27 changed files with 216 additions and 172 deletions

View File

@@ -34,6 +34,9 @@ export const handleSchedulePhaseChanges = async ({
return;
}
// Step 1: Activate scheduled products; checkout trial-end updates have no schedule phase change.
await activateScheduledCustomerProducts({ ctx, eventContext });
// Check if phase possibly changed (items changed and schedule exists)
const phasePossiblyChanged =
notNullish(previousAttributes?.items) &&
@@ -52,10 +55,7 @@ export const handleSchedulePhaseChanges = async ({
`[handleSchedulePhaseChanges] sub: ${stripeSubscription.id}, now: ${formatMs(nowMs)}, currentPhase: ${currentPhaseIndex + 1}/${stripeSubscriptionSchedule.phases.length}`,
);
// Step 1: Activate scheduled customer products
await activateScheduledCustomerProducts({ ctx, eventContext });
// Step 2: Expire ended customer products (uses updated customerProducts from step 1)
// Step 2: Expire ended customer products (uses updated customerProducts)
await expireEndedCustomerProducts({ ctx, eventContext });
// Step 3: Release schedule if at last phase

View File

@@ -1,34 +0,0 @@
import {
type AttachBillingContext,
EntInterval,
type FullCusProduct,
getCycleEnd,
} from "@autumn/shared";
import type { AttachStartTiming } from "./getAttachStartTiming";
export const applyAttachStartDates = ({
newFullCustomerProduct,
attachBillingContext,
attachStartTiming,
}: {
newFullCustomerProduct: FullCusProduct;
attachBillingContext: AttachBillingContext;
attachStartTiming: AttachStartTiming;
}): void => {
const { billingStartsAt, currentEpochMs } = attachBillingContext;
const { accessStartsAt, billingAnchorStartsAt } = attachStartTiming;
if (billingStartsAt !== undefined) {
for (const customerEntitlement of newFullCustomerProduct.customer_entitlements) {
if (customerEntitlement.next_reset_at === null) continue;
customerEntitlement.next_reset_at = getCycleEnd({
anchor: billingStartsAt,
interval: customerEntitlement.entitlement.interval ?? EntInterval.Month,
intervalCount: customerEntitlement.entitlement.interval_count,
now: billingStartsAt,
});
}
}
if (accessStartsAt === billingAnchorStartsAt) return;
newFullCustomerProduct.starts_at = accessStartsAt ?? currentEpochMs;
};

View File

@@ -7,8 +7,6 @@ import {
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { carryOverUsagesToExistingUsagesConfig } from "@/internal/billing/v2/utils/handleCarryOvers/carryOverUtils";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
import { applyAttachStartDates } from "./applyAttachStartDates";
import { getAttachStartTiming } from "./getAttachStartTiming";
const getScheduledBillingCycleAnchorResetAt = ({
requestedBillingCycleAnchor,
@@ -47,6 +45,7 @@ export const computeAttachNewCustomerProduct = ({
fullCustomer,
currentCustomerProduct,
planTiming,
endOfCycleMs,
stripeSubscription,
stripeSubscriptionSchedule,
currentEpochMs,
@@ -57,6 +56,8 @@ export const computeAttachNewCustomerProduct = ({
transitionConfig,
externalId,
requestedBillingCycleAnchor,
resetCycleAnchorMs,
accessStartsAt,
} = attachBillingContext;
const currentCustomerEntitlements =
@@ -72,12 +73,8 @@ export const computeAttachNewCustomerProduct = ({
.map((ce) => ce.entitlement.feature.id),
);
const attachStartTiming = getAttachStartTiming({
attachBillingContext,
params,
});
const { billingAnchorStartsAt, resetCycleAnchor, status } = attachStartTiming;
const isScheduled = planTiming === "end_of_cycle";
const startsAt = params.starts_at ?? (isScheduled ? endOfCycleMs : undefined);
let existingUsagesConfig: ExistingUsagesConfig | undefined =
!isScheduled && currentCustomerProduct
@@ -110,7 +107,7 @@ export const computeAttachNewCustomerProduct = ({
featureQuantities,
// existingUsages: isScheduled ? undefined : existingUsages,
// existingRollovers,
resetCycleAnchor,
resetCycleAnchor: resetCycleAnchorMs,
now: currentEpochMs,
freeTrial: trialContext?.freeTrial ?? null,
trialEndsAt: trialContext?.trialEndsAt ?? undefined,
@@ -125,8 +122,8 @@ export const computeAttachNewCustomerProduct = ({
// subscriptionId: isScheduled ? undefined : stripeSubscription?.id,
subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
status,
startsAt: billingAnchorStartsAt,
startsAt,
accessStartsAt,
externalId,
billingCycleAnchorResetsAt: getScheduledBillingCycleAnchorResetAt({
requestedBillingCycleAnchor,
@@ -135,11 +132,5 @@ export const computeAttachNewCustomerProduct = ({
},
});
applyAttachStartDates({
newFullCustomerProduct,
attachBillingContext,
attachStartTiming,
});
return newFullCustomerProduct;
};

View File

@@ -61,7 +61,7 @@ export const computeAttachPlan = ({
const shouldBuildLineItems = shouldBuildImmediateLineItems({
planTiming,
customerProductStatus: newCustomerProduct.status,
billingStartsAt: attachBillingContext.billingStartsAt,
accessStartsAt: attachBillingContext.accessStartsAt,
});
const { allLineItems: lineItems, updateCustomerEntitlements } =

View File

@@ -41,7 +41,7 @@ export const computeAttachTransitionUpdates = ({
}
const startsAt = params.starts_at;
const transitionEndMs = isFutureStartDate(startsAt, currentEpochMs)
const transitionAtMs = isFutureStartDate(startsAt, currentEpochMs)
? startsAt
: endOfCycleMs;
@@ -54,7 +54,7 @@ export const computeAttachTransitionUpdates = ({
: undefined,
canceled: true,
canceled_at: currentEpochMs,
ended_at: transitionEndMs,
ended_at: transitionAtMs,
},
};
};

View File

@@ -1,73 +0,0 @@
import type { AttachBillingContext, AttachParamsV1 } from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
export type AttachStartTiming = {
accessStartsAt?: number;
billingAnchorStartsAt?: number;
resetCycleAnchor: number | "now";
status?: CusProductStatus;
};
const resolveResetCycleAnchor = ({
billingStartsAt,
billingAnchorStartsAt,
resetCycleAnchorMs,
}: {
billingStartsAt?: number;
billingAnchorStartsAt?: number;
resetCycleAnchorMs: number | "now";
}): number | "now" => {
if (billingStartsAt !== undefined) return billingStartsAt;
if (resetCycleAnchorMs !== "now") return resetCycleAnchorMs;
return billingAnchorStartsAt ?? resetCycleAnchorMs;
};
const resolveCustomerProductStatus = ({
billingStartsAt,
isScheduled,
}: {
billingStartsAt?: number;
isScheduled: boolean;
}): CusProductStatus | undefined => {
if (billingStartsAt !== undefined) return CusProductStatus.Active;
if (isScheduled) return CusProductStatus.Scheduled;
return undefined;
};
export const getAttachStartTiming = ({
attachBillingContext,
params,
}: {
attachBillingContext: AttachBillingContext;
params: AttachParamsV1;
}): AttachStartTiming => {
const {
planTiming,
endOfCycleMs,
resetCycleAnchorMs,
currentEpochMs,
billingStartsAt,
} = attachBillingContext;
const isScheduled = planTiming === "end_of_cycle";
const requestedStartsAt =
params.starts_at ?? (isScheduled ? endOfCycleMs : undefined);
const billingAnchorStartsAt = billingStartsAt ?? requestedStartsAt;
const accessStartsAt =
billingStartsAt !== undefined ? currentEpochMs : requestedStartsAt;
const resetCycleAnchor = resolveResetCycleAnchor({
billingStartsAt,
billingAnchorStartsAt,
resetCycleAnchorMs,
});
const status = resolveCustomerProductStatus({
billingStartsAt,
isScheduled,
});
return {
accessStartsAt,
billingAnchorStartsAt,
resetCycleAnchor,
status,
};
};

View File

@@ -3,13 +3,13 @@ import { type AttachBillingContext, CusProductStatus } from "@autumn/shared";
export const shouldBuildImmediateLineItems = ({
planTiming,
customerProductStatus,
billingStartsAt,
accessStartsAt,
}: {
planTiming: AttachBillingContext["planTiming"];
customerProductStatus: CusProductStatus;
billingStartsAt?: number;
accessStartsAt?: number;
}): boolean => {
if (billingStartsAt !== undefined) return false;
if (accessStartsAt !== undefined) return false;
if (planTiming !== "immediate") return false;
return customerProductStatus !== CusProductStatus.Scheduled;
};

View File

@@ -1,6 +1,6 @@
import { type AttachParamsV1, isFutureStartDate } from "@autumn/shared";
export const getAttachBillingStartsAt = ({
export const getAttachAccessStartsAt = ({
params,
currentEpochMs,
}: {
@@ -12,9 +12,9 @@ export const getAttachBillingStartsAt = ({
params.enable_plan_immediately !== true ||
startsAt === undefined ||
!isFutureStartDate(startsAt, currentEpochMs)
){
) {
return undefined;
}
return startsAt;
return currentEpochMs;
};

View File

@@ -25,7 +25,7 @@ import { setupResetCycleAnchor } from "@/internal/billing/v2/setup/setupResetCyc
import { setupTransitionConfigs } from "@/internal/billing/v2/setup/setupTransitionConfigs";
import { setupAdjustableQuantities } from "../../../setup/setupAdjustableQuantities";
import { setupAnchorResetRefund } from "../../../setup/setupAnchorResetRefund";
import { getAttachBillingStartsAt } from "./getAttachBillingStartsAt";
import { getAttachAccessStartsAt } from "./getAttachAccessStartsAt";
import { setupAttachCheckoutMode } from "./setupAttachCheckoutMode";
import { setupAttachEndOfCycleMs } from "./setupAttachEndOfCycleMs";
import { setupAttachProductContext } from "./setupAttachProductContext";
@@ -183,12 +183,6 @@ export const setupAttachBillingContext = async ({
billingCycleAnchorMs = trialContext.trialEndsAt;
}
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
newFullProduct: attachProduct,
});
const endOfCycleMs =
contextOverride.endOfCycleMsOverride ??
setupAttachEndOfCycleMs({
@@ -199,15 +193,24 @@ export const setupAttachBillingContext = async ({
currentEpochMs,
});
const attachStartsAt =
params.starts_at ?? (planTiming === "end_of_cycle" ? endOfCycleMs : undefined);
const hasFutureStartDate = isFutureStartDate(
params.starts_at,
currentEpochMs,
);
const billingStartsAt = getAttachBillingStartsAt({
const accessStartsAt = getAttachAccessStartsAt({
params,
currentEpochMs,
});
const resetCycleAnchorMs = setupResetCycleAnchor({
billingCycleAnchorMs,
customerProduct: undefined, // don't pass in current customer product here (paid products should have the reset cycle anchor correctly...)
newFullProduct: attachProduct,
startsAt: attachStartsAt,
});
const checkoutMode = setupAttachCheckoutMode({
paymentMethod,
redirectMode: params.redirect_mode,
@@ -255,7 +258,7 @@ export const setupAttachBillingContext = async ({
invoiceMode,
enablePlanImmediately: params.enable_plan_immediately ?? false,
billingStartsAt,
accessStartsAt,
customPrices,
customEnts,

View File

@@ -18,6 +18,7 @@ const computeScheduledAddOnsToDelete = ({
}: {
billingContext: UpdateSubscriptionBillingContext;
}): FullCusProduct[] => {
// Immediate main-plan cancellation invalidates future add-on phases in the same scope.
const { cancelAction, customerProduct, fullCustomer } = billingContext;
if (cancelAction !== "cancel_immediately") return [];
if (!cp(customerProduct).main().recurring().valid) return [];

View File

@@ -17,14 +17,21 @@ export const buildCustomerProductsForStripe = ({
autumnBillingPlan: AutumnBillingPlan;
finalCustomerProducts: FullCusProduct[];
}): FullCusProduct[] => {
const { billingStartsAt } = billingContext;
if (billingStartsAt === undefined) return finalCustomerProducts;
if (billingContext.accessStartsAt === undefined) return finalCustomerProducts;
const insertedCustomerProductIds = new Set(
autumnBillingPlan.insertCustomerProducts.map(
(customerProduct) => customerProduct.id,
),
);
const billingStartMs = autumnBillingPlan.insertCustomerProducts.find(
(customerProduct) =>
customerProduct.access_starts_at !== undefined &&
customerProduct.access_starts_at !== null,
)?.starts_at;
if (billingStartMs === undefined) return finalCustomerProducts;
const outgoingCustomerProduct =
autumnBillingPlan.updateCustomerProduct?.customerProduct;
@@ -33,7 +40,7 @@ export const buildCustomerProductsForStripe = ({
return {
...customerProduct,
status: CusProductStatus.Scheduled,
starts_at: billingStartsAt,
starts_at: billingStartMs,
};
}
@@ -41,7 +48,7 @@ export const buildCustomerProductsForStripe = ({
return {
...outgoingCustomerProduct,
status: CusProductStatus.Active,
ended_at: billingStartsAt,
ended_at: billingStartMs,
canceled: true,
canceled_at: billingContext.currentEpochMs,
};

View File

@@ -12,9 +12,6 @@ export const getCheckoutSubscriptionTrialEnd = ({
deferredStartsAt?: number;
}): number | undefined => {
if (mode !== "subscription") return undefined;
if (billingContext.billingStartsAt) {
return msToSeconds(billingContext.billingStartsAt);
}
if (deferredStartsAt) return msToSeconds(deferredStartsAt);
if (!billingContext.trialContext?.trialEndsAt) return undefined;

View File

@@ -8,16 +8,28 @@ import {
/**
* Determine the billing cycle anchor based on product transitions.
*
* For future starts, feature resets anchor to the billing start (`startsAt`).
*/
export const setupResetCycleAnchor = ({
billingCycleAnchorMs,
customerProduct,
newFullProduct,
startsAt,
}: {
billingCycleAnchorMs: number | "now";
customerProduct?: FullCusProduct;
newFullProduct: FullProduct;
startsAt?: number;
}): number | "now" => {
const hasFutureBillingStart = startsAt !== undefined;
const shouldAnchorToBillingStart =
hasFutureBillingStart && !customerProduct;
if (shouldAnchorToBillingStart) {
return startsAt;
}
if (!customerProduct) {
return billingCycleAnchorMs;
}

View File

@@ -35,6 +35,7 @@ export const initCustomerProduct = ({
apiSemver,
externalId,
billingCycleAnchorResetsAt,
accessStartsAt,
} = initOptions ?? {};
const internalEntityId = fullCustomer.entity?.internal_id;
@@ -48,7 +49,11 @@ export const initCustomerProduct = ({
// 1 minute tolerance to determine if customer product should be scheduled. (for test clock time frozen issues)
const TOLERANCE_MS = ms.minutes(1);
if (startsAt && startsAt > now + TOLERANCE_MS) {
const effectiveAccessStartsAt = accessStartsAt ?? startsAt;
if (
effectiveAccessStartsAt &&
effectiveAccessStartsAt > now + TOLERANCE_MS
) {
return CusProductStatus.Scheduled;
}
@@ -85,6 +90,7 @@ export const initCustomerProduct = ({
// processor: null,
starts_at: startsAt,
access_starts_at: accessStartsAt ?? null,
ended_at: endedAt,
trial_ends_at: trialEndsAt,

View File

@@ -63,7 +63,8 @@ test.concurrent(`${chalk.yellowBright("starts_at: enable_plan_immediately activa
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(cusProduct.starts_at - advancedTo)).toBeLessThan(
expect(cusProduct.starts_at).toBe(startDate);
expect(Math.abs(cusProduct.access_starts_at! - advancedTo)).toBeLessThan(
ms.minutes(10),
);
expectResetAnchoredTo({
@@ -123,9 +124,10 @@ test.concurrent(`${chalk.yellowBright("starts_at: upgrade access can start befor
});
expect(premiumCustomerProduct.status).toBe(CusProductStatus.Active);
expect(premiumCustomerProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(premiumCustomerProduct.starts_at - advancedTo)).toBeLessThan(
ms.minutes(10),
);
expect(premiumCustomerProduct.starts_at).toBe(startsAt);
expect(
Math.abs(premiumCustomerProduct.access_starts_at! - advancedTo),
).toBeLessThan(ms.minutes(10));
expectResetAnchoredTo({
cusProduct: premiumCustomerProduct,
featureId: TestFeature.Messages,
@@ -181,9 +183,10 @@ test.concurrent(`${chalk.yellowBright("starts_at: add-on access can start before
});
expect(addonCustomerProduct.status).toBe(CusProductStatus.Active);
expect(addonCustomerProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(addonCustomerProduct.starts_at - advancedTo)).toBeLessThan(
ms.minutes(10),
);
expect(addonCustomerProduct.starts_at).toBe(startsAt);
expect(
Math.abs(addonCustomerProduct.access_starts_at! - advancedTo),
).toBeLessThan(ms.minutes(10));
expectResetAnchoredTo({
cusProduct: addonCustomerProduct,
featureId: TestFeature.Words,

View File

@@ -77,6 +77,7 @@ test.concurrent(`${chalk.yellowBright("starts_at: future attach creates schedule
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.access_starts_at).toBeNull();
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expectResetAnchoredTo({

View File

@@ -3,10 +3,49 @@ import { type AttachParamsV1Input, CusProductStatus, ms } from "@autumn/shared";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import type { TestContext } from "@tests/utils/testInitUtils/createTestContext";
import chalk from "chalk";
import { handleSchedulePhaseChanges } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/tasks/handleSchedulePhaseChanges/handleSchedulePhaseChanges";
import type { StripeSubscriptionUpdatedContext } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/stripeSubscriptionUpdatedContext";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { CusService } from "@/internal/customers/CusService";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { getCustomerProduct, triggerSubscriptionCreated } from "./utils";
const triggerSubscriptionUpdated = async ({
ctx,
stripeSubId,
fullCustomer,
nowMs,
}: {
ctx: TestContext;
stripeSubId: string;
fullCustomer: StripeSubscriptionUpdatedContext["fullCustomer"];
nowMs: number;
}) => {
await handleSchedulePhaseChanges({
ctx: {
...ctx,
stripeEvent: {} as StripeWebhookContext["stripeEvent"],
},
eventContext: {
stripeSubscription: {
id: stripeSubId,
schedule: null,
},
previousAttributes: {
status: "trialing",
},
fullCustomer,
customerProducts: [...fullCustomer.customer_products],
nowMs,
updatedCustomerProducts: [],
deletedCustomerProducts: [],
insertedCustomerProducts: [],
} as unknown as StripeSubscriptionUpdatedContext,
});
};
test.concurrent(`${chalk.yellowBright("starts_at: subscription.created links scheduled product")}`, async () => {
const customerId = "attach-start-date-webhook";
const pro = products.pro({
@@ -172,6 +211,63 @@ test.concurrent(`${chalk.yellowBright("starts_at: subscription.created retry can
expect(activatedProduct.status).toBe(CusProductStatus.Active);
});
test.concurrent(`${chalk.yellowBright("starts_at: subscription.updated activates checkout trial-start product")}`, async () => {
const customerId = "attach-start-date-webhook-sub-updated";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = advancedTo + ms.days(1);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const stripeSubId = "sub_attach_start_date_updated";
await CusProductService.update({
ctx,
cusProductId: scheduledProduct.id,
updates: {
subscription_ids: [stripeSubId],
},
});
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
await triggerSubscriptionUpdated({
ctx,
stripeSubId,
fullCustomer,
nowMs: startDate + ms.minutes(5),
});
const activatedProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(activatedProduct.status).toBe(CusProductStatus.Active);
expect(activatedProduct.subscription_ids).toEqual([stripeSubId]);
});
test.concurrent(`${chalk.yellowBright("starts_at: subscription.created ignores missing schedule")}`, async () => {
const customerId = "attach-start-date-webhook-no-schedule";
const pro = products.pro({

View File

@@ -257,6 +257,7 @@ const buildCustomerProduct = ({
canceled_at: null,
ended_at: null,
starts_at: now,
access_starts_at: null,
options: [],
product_id: product.id,
free_trial_id: null,

View File

@@ -92,8 +92,8 @@ export interface BillingContext {
// session is required. Mirrors invoice-mode enable_plan_immediately for the
// stripe_checkout flow.
enablePlanImmediately?: boolean;
// When set, Autumn access starts now while Stripe billing starts at this time.
billingStartsAt?: number;
// When set, Autumn access starts at this time while billing may start later.
accessStartsAt?: number;
/** Identifies the Autumn action driving this billing context. Stamped onto Stripe
* subscription metadata so downstream webhook handlers can recognise Autumn-driven
* subscription mutations and skip auto-sync. */

View File

@@ -65,6 +65,7 @@ export interface InitFullCustomerProductOptions {
canceledAt?: number;
status?: CusProductStatus; // Used for scheduling product
startsAt?: number; // Used for scheduling product
accessStartsAt?: number;
endedAt?: number; // Used for scheduling product
// Optional + random

View File

@@ -41,6 +41,7 @@ export const CusProductSchema = z.object({
canceled: z.boolean().default(false),
starts_at: z.number().default(Date.now()),
access_starts_at: z.number().optional().nullable(),
trial_ends_at: z.number().optional().nullable(),
billing_cycle_anchor_resets_at: z.number().optional().nullable(),
canceled_at: z.number().optional().nullable(),

View File

@@ -36,6 +36,7 @@ export const customerProducts = pgTable(
canceled_at: numeric({ mode: "number" }),
ended_at: numeric({ mode: "number" }),
starts_at: numeric({ mode: "number" }),
access_starts_at: numeric({ mode: "number" }),
options: jsonb().array(),
product_id: text("product_id"),
free_trial_id: text("free_trial_id"),

View File

@@ -107,7 +107,7 @@ function SubscriptionDetailItems({
}
export function SubscriptionDetailSheet() {
const { customer } = useCusQuery();
const { customer, testClockFrozenTimeMs } = useCusQuery();
const { stripeAccount } = useOrgStripeQuery();
const env = useEnv();
const itemId = useSheetStore((s) => s.itemId);
@@ -144,6 +144,7 @@ export function SubscriptionDetailSheet() {
const isScheduled = cusProduct.status === CusProductStatus.Scheduled;
const canCancel = !isExpired;
const canUpdate = !isExpired && !isScheduled;
const nowMs = testClockFrozenTimeMs ?? Date.now();
const prepaidDisplayQuantities = backendToDisplayQuantity({
backendOptions: cusProduct.options,
prepaidItems,
@@ -302,10 +303,11 @@ export function SubscriptionDetailSheet() {
canceled_at={cusProduct.canceled_at ?? undefined}
trialing={
isCustomerProductTrialing(cusProduct, {
nowMs: Date.now(),
nowMs,
}) || false
}
trial_ends_at={cusProduct.trial_ends_at ?? undefined}
nowMs={nowMs}
/>
}
/>

View File

@@ -44,15 +44,24 @@ export const CustomerProductsColumns = [
{
header: "Status",
accessorKey: "status",
cell: ({ row }: { row: Row<FullCusProduct> }) => {
cell: ({
row,
table,
}: {
row: Row<FullCusProduct>;
table: Table<FullCusProduct>;
}) => {
const nowMs = (table.options.meta as { nowMs?: number })?.nowMs;
return (
<CustomerProductsStatus
status={row.original.status}
starts_at={row.original.starts_at ?? undefined}
canceled={row.original.canceled}
canceled_at={row.original.canceled_at ?? undefined}
trialing={isCustomerProductTrialing(row.original) || false}
trialing={isCustomerProductTrialing(row.original, { nowMs }) || false}
trial_ends_at={row.original.trial_ends_at ?? undefined}
nowMs={nowMs}
/>
);
},

View File

@@ -1,6 +1,6 @@
import { CusProductStatus, formatMsToDate } from "@autumn/shared";
import { DotIcon, ExclamationMarkIcon, XIcon } from "@phosphor-icons/react";
import { formatDistanceToNow } from "date-fns";
import { formatDistance } from "date-fns";
import { BanIcon, CalendarIcon, CheckIcon, ClockIcon } from "lucide-react";
import {
Tooltip,
@@ -15,6 +15,7 @@ const StatusItem = ({
text,
trial_ends_at,
canceled_at,
nowMs,
tooltip,
className,
}: {
@@ -22,15 +23,16 @@ const StatusItem = ({
text: string;
trial_ends_at?: number;
canceled_at?: number;
nowMs?: number;
tooltip?: boolean;
className?: string;
}) => {
const getSubtext = () => {
if (trial_ends_at) {
return `${formatDistanceToNow(trial_ends_at)} left`;
return `${formatDistance(trial_ends_at, nowMs ?? Date.now())} left`;
}
if (canceled_at) {
return `${formatDistanceToNow(canceled_at)} ago`;
return `${formatDistance(canceled_at, nowMs ?? Date.now())} ago`;
}
return null;
};
@@ -75,6 +77,7 @@ export const CustomerProductsStatus = ({
trialing,
trial_ends_at,
starts_at,
nowMs,
}: {
status?: CusProductStatus;
tooltip?: boolean;
@@ -83,7 +86,10 @@ export const CustomerProductsStatus = ({
trialing?: boolean;
trial_ends_at?: number;
starts_at?: number;
nowMs?: number;
}) => {
const effectiveNowMs = nowMs ?? Date.now();
// Expired status takes priority over canceled
if (status === CusProductStatus.Expired) {
return (
@@ -115,7 +121,12 @@ export const CustomerProductsStatus = ({
// If product is canceled, show that status
if (canceled) {
return (
<StatusItem text="Cancelling" tooltip={tooltip} canceled_at={canceled_at}>
<StatusItem
text="Cancelling"
tooltip={tooltip}
canceled_at={canceled_at}
nowMs={effectiveNowMs}
>
<BanIcon
className="text-white bg-orange-500 dark:bg-orange-600 rounded-full p-0.5"
size={12}
@@ -126,7 +137,12 @@ export const CustomerProductsStatus = ({
if (trialing) {
return (
<StatusItem text="Trial" trial_ends_at={trial_ends_at} tooltip={tooltip}>
<StatusItem
text="Trial"
trial_ends_at={trial_ends_at}
tooltip={tooltip}
nowMs={effectiveNowMs}
>
<ClockIcon
className="text-white bg-blue-500 dark:bg-blue-600 rounded-full p-0.5"
size={12}

View File

@@ -70,6 +70,7 @@ export function CustomerProductsTable() {
subscriptions,
hasEntities,
purchases,
testClockFrozenTimeMs,
} = useCustomerProductsData();
const { setEntityId } = useEntity();
@@ -130,6 +131,7 @@ export function CustomerProductsTable() {
onUncancelClick: handleUncancelClick,
onTransferClick: handleTransferClick,
hasEntities,
nowMs: testClockFrozenTimeMs,
};
const subscriptionTable = useCustomerTable({

View File

@@ -30,7 +30,7 @@ function filterBySelectedEntity({
}
export function useCustomerProductsData() {
const { customer, isLoading } = useCusQuery();
const { customer, isLoading, testClockFrozenTimeMs } = useCusQuery();
const { entityId } = useEntity();
const [showExpired, setShowExpired] = useQueryState(
"customerProductsShowExpired",
@@ -81,6 +81,7 @@ export function useCustomerProductsData() {
return {
customer,
isLoading,
testClockFrozenTimeMs,
showExpired,
setShowExpired,
entityId,