chore: cleanup & use accessStartsAt
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export const computeAttachPlan = ({
|
||||
const shouldBuildLineItems = shouldBuildImmediateLineItems({
|
||||
planTiming,
|
||||
customerProductStatus: newCustomerProduct.status,
|
||||
billingStartsAt: attachBillingContext.billingStartsAt,
|
||||
accessStartsAt: attachBillingContext.accessStartsAt,
|
||||
});
|
||||
|
||||
const { allLineItems: lineItems, updateCustomerEntitlements } =
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user