Merge branch 'feat/attach-v2' into dev
This commit is contained in:
@@ -3,9 +3,12 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'integration/billing/update-subscription' \
|
||||
# 'integration/billing/stripe-webhooks' \
|
||||
# 'integration/billing/autumn-webhooks' \
|
||||
# 'integration/crud/customers' \
|
||||
'integration/billing/stripe-webhooks' \
|
||||
'integration/billing/autumn-webhooks' \
|
||||
'integration/billing/migrations' \
|
||||
'integration/billing/cron' \
|
||||
'integration/crud/customers' \
|
||||
# 'integration/billing/attach' \
|
||||
|
||||
|
||||
# 'integration/billing/attach' \
|
||||
|
||||
@@ -4,9 +4,9 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
export TEST_FILE_CONCURRENCY=6
|
||||
|
||||
# 'attach/migrations' \
|
||||
BUN_PARALLEL_V2 \
|
||||
'attach/basic' \
|
||||
'attach/migrations' \
|
||||
'attach/upgrade' \
|
||||
'attach/downgrade' \
|
||||
'attach/free' \
|
||||
@@ -17,16 +17,10 @@ BUN_PARALLEL_V2 \
|
||||
'attach/response' \
|
||||
'interval/upgrade' \
|
||||
'interval/multiSub' \
|
||||
'billing/new-billing-subscription' \
|
||||
'billing/invoice-action-required' \
|
||||
'billing/legacy/attach' \
|
||||
--max=6
|
||||
|
||||
# From attach/migrations is new stuff...
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/attach/entities'
|
||||
# 'billing/new-billing-subscription' \
|
||||
# 'billing/legacy/attach' \
|
||||
# 'server/tests/attach/entities'
|
||||
# --max=6
|
||||
|
||||
|
||||
# 'attach/updateEnts' \
|
||||
# 'attach/newVersion' \
|
||||
# 'billing/invoice-action-required' \
|
||||
@@ -4,7 +4,7 @@ source "$(dirname "$0")/config.sh"
|
||||
export TEST_FILE_CONCURRENCY=6
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'merged/downgrade' \
|
||||
|
||||
'merged/separate' \
|
||||
'merged/add' \
|
||||
'merged/group' \
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import {
|
||||
BillingVersion,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
ms,
|
||||
@@ -7,7 +9,6 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import type { ExpandedStripeCustomer } from "@/external/stripe/customers/operations/getExpandedStripeCustomer";
|
||||
import type { ExpandedStripeSubscription } from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Common fields between InvoiceCreatedContext and StripeSubscriptionDeletedContext.
|
||||
@@ -93,5 +94,7 @@ export const buildBillingContextForArrearInvoice = ({
|
||||
stripeCustomer: stripeSubscription.customer,
|
||||
stripeSubscription,
|
||||
paymentMethod: paymentMethod ?? undefined,
|
||||
|
||||
billingVersion: BillingVersion.V2,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { FullCusEntWithFullCusProduct, LineItem } from "@autumn/shared";
|
||||
import type {
|
||||
BillingContext,
|
||||
FullCusEntWithFullCusProduct,
|
||||
LineItem,
|
||||
UpdateCustomerEntitlement,
|
||||
} from "@autumn/shared";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import { setupStripeDiscountsForBilling } from "@/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling";
|
||||
import { applyStripeDiscountsToLineItems } from "@/internal/billing/v2/providers/stripe/utils/discounts/applyStripeDiscountsToLineItems";
|
||||
import type { UpdateCustomerEntitlement } from "@autumn/shared";
|
||||
import { customerProductToArrearLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems";
|
||||
import {
|
||||
type BaseWebhookEventContext,
|
||||
@@ -57,7 +60,7 @@ export const eventContextToArrearLineItems = ({
|
||||
customerProduct,
|
||||
billingContext,
|
||||
filters: { cusEntFilter },
|
||||
updateNextResetAt: true,
|
||||
options: { updateNextResetAt: true },
|
||||
});
|
||||
lineItems.push(...productLineItems);
|
||||
updateCustomerEntitlements.push(...productUpdates);
|
||||
|
||||
@@ -100,7 +100,7 @@ export const setupInvoiceCreatedContext = async ({
|
||||
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
|
||||
return isCustomerProductOnStripeSubscriptionSchedule({
|
||||
customerProduct: cp,
|
||||
stripeSubscriptionScheduleId: scheduleId,
|
||||
stripeSubscriptionScheduleId: scheduleId ?? undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ export const upsertAutumnInvoice = async ({
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
})
|
||||
.or.onStripeSchedule({
|
||||
stripeSubscriptionScheduleId: scheduleId,
|
||||
stripeSubscriptionScheduleId: scheduleId ?? undefined,
|
||||
})
|
||||
.scheduled()
|
||||
.hasStarted({ nowMs: eventContext.nowMs });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { _legacyListRawEvents } from "./_legacyListRawEvents.js";
|
||||
import { _legacyListRawEvents } from "@/internal/analytics/actions/_legacyListRawEvents";
|
||||
import { aggregate } from "./aggregate";
|
||||
import { getCountAndSum } from "./getCountAndSum.js";
|
||||
import { getEventById } from "./getEventById.js";
|
||||
|
||||
@@ -116,6 +116,7 @@ export const handleAttach = createRoute({
|
||||
product_ids: products.map((p) => p.id),
|
||||
customer_id: customer.id || customer.internal_id,
|
||||
...response,
|
||||
checkout_url: response.checkout_url ?? undefined,
|
||||
invoice: response.invoice
|
||||
? attachToInvoiceResponse({ invoice: response.invoice })
|
||||
: undefined,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
cusProductToPrices,
|
||||
cusProductToProduct,
|
||||
isUsagePrice,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
toProductItem,
|
||||
UsageModel,
|
||||
} from "@autumn/shared";
|
||||
@@ -57,7 +57,7 @@ export const previewToCheckoutRes = async ({
|
||||
|
||||
if (preview.due_today && preview.due_today.line_items.length > 0) {
|
||||
lines = preview.due_today.line_items
|
||||
.map((li: PreviewLineItem) => {
|
||||
.map((li: LegacyPreviewLineItem) => {
|
||||
const price = allPrices.find((p) => p.id === li.price_id);
|
||||
|
||||
if (!price) {
|
||||
|
||||
@@ -38,6 +38,5 @@ export async function createAutumnCheckout({
|
||||
stripe: {},
|
||||
autumn: { checkout },
|
||||
},
|
||||
checkoutUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { BillingContextOverride, PlanTiming } from "@autumn/shared";
|
||||
import {
|
||||
type AttachBodyV0,
|
||||
type AttachParamsV0,
|
||||
BillingVersion,
|
||||
} from "@autumn/shared";
|
||||
import { type AttachParamsV0, BillingVersion } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext";
|
||||
@@ -13,12 +9,12 @@ import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams
|
||||
|
||||
export const legacyAttach = async ({
|
||||
ctx,
|
||||
body,
|
||||
// body,
|
||||
attachParams,
|
||||
planTiming,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
body: AttachBodyV0;
|
||||
// body: AttachBodyV0;
|
||||
attachParams: AttachParams;
|
||||
planTiming: PlanTiming;
|
||||
}) => {
|
||||
@@ -52,17 +48,17 @@ export const legacyAttach = async ({
|
||||
customer_id: fullCustomer.id || fullCustomer.internal_id,
|
||||
entity_id: fullCustomer.entity?.id,
|
||||
product_id: fullProduct.id,
|
||||
items: body.items,
|
||||
// items: body.items,
|
||||
// version: body.version,
|
||||
// invoice: body.invoice,
|
||||
// free_trial: body.free_trial === false ? null : undefined,
|
||||
|
||||
version: body.version,
|
||||
invoice: body.invoice,
|
||||
enable_product_immediately: body.enable_product_immediately,
|
||||
finalize_invoice: body.finalize_invoice,
|
||||
invoice: attachParams.invoiceOnly,
|
||||
enable_product_immediately: true,
|
||||
finalize_invoice: attachParams.finalizeInvoice,
|
||||
|
||||
redirect_mode: "if_required",
|
||||
|
||||
free_trial: body.free_trial === false ? null : undefined,
|
||||
|
||||
plan_schedule: planTiming,
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const renew = async ({
|
||||
|
||||
// Current customer product
|
||||
const currentCustomerProduct = findActiveCustomerProductById({
|
||||
fullCustomer: attachParams.customer,
|
||||
fullCus: attachParams.customer,
|
||||
productId: fullProduct.id,
|
||||
internalEntityId: attachParams.customer.entity?.internal_id,
|
||||
});
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import {
|
||||
type AttachBillingContext,
|
||||
BillingVersion,
|
||||
findMainScheduledCustomerProductByGroup,
|
||||
InternalError,
|
||||
type PlanTiming,
|
||||
secondsToMs,
|
||||
type TrialContext,
|
||||
} from "@autumn/shared";
|
||||
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions/utils/convertStripeSubscription";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupAttachEndOfCycleMs } from "@/internal/billing/v2/actions/attach/setup/setupAttachEndOfCycleMs";
|
||||
import { setupUpgradeDowngradeBillingContext } from "@/internal/billing/v2/actions/legacy/setup/setupUpgradeBillingContext";
|
||||
import { setupUpdateSubscriptionTrialContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionTrialContext";
|
||||
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling";
|
||||
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionScheduleForBilling";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams";
|
||||
|
||||
export const attachParamsToAttachBillingContext = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
planTiming,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
planTiming: PlanTiming;
|
||||
}): Promise<AttachBillingContext> => {
|
||||
if (attachParams.products.length !== 1) {
|
||||
throw new InternalError({ message: "attachParams.products.length !== 1" });
|
||||
}
|
||||
|
||||
// Full product
|
||||
const fullProduct = {
|
||||
...attachParams.products[0],
|
||||
prices: attachParams.prices,
|
||||
entitlements: attachParams.entitlements,
|
||||
};
|
||||
|
||||
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
||||
ctx,
|
||||
fullCus: attachParams.customer,
|
||||
product: fullProduct,
|
||||
});
|
||||
|
||||
const stripeSubscriptionSchedule =
|
||||
await fetchStripeSubscriptionScheduleForBilling({
|
||||
ctx,
|
||||
fullCus: attachParams.customer,
|
||||
products: [fullProduct],
|
||||
subscriptionScheduleId: stripeSubscriptionToScheduleId({
|
||||
stripeSubscription,
|
||||
}),
|
||||
});
|
||||
|
||||
const currentEpochMs = attachParams.now ?? Date.now();
|
||||
|
||||
const billingCycleAnchorMs =
|
||||
secondsToMs(stripeSubscription?.billing_cycle_anchor) ?? "now";
|
||||
|
||||
const resetCycleAnchorMs = billingCycleAnchorMs;
|
||||
|
||||
const currentCustomerProduct = setupUpgradeDowngradeBillingContext({
|
||||
attachParams,
|
||||
});
|
||||
|
||||
const scheduledCustomerProduct = findMainScheduledCustomerProductByGroup({
|
||||
fullCustomer: attachParams.customer,
|
||||
productGroup: fullProduct.group,
|
||||
});
|
||||
|
||||
const endOfCycleMs = setupAttachEndOfCycleMs({
|
||||
planTiming,
|
||||
currentCustomerProduct,
|
||||
stripeSubscription,
|
||||
currentEpochMs,
|
||||
});
|
||||
|
||||
const invoiceMode = attachParams.invoiceOnly
|
||||
? {
|
||||
finalizeInvoice: attachParams.finalizeInvoice ?? false,
|
||||
enableProductImmediately: true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const paramsFreeTrial = attachParams.freeTrial;
|
||||
let trialContext: TrialContext | undefined;
|
||||
if (paramsFreeTrial && !attachParams.config?.disableTrial) {
|
||||
trialContext = setupUpdateSubscriptionTrialContext({
|
||||
stripeSubscription,
|
||||
customerProduct: currentCustomerProduct,
|
||||
currentEpochMs,
|
||||
params: {
|
||||
free_trial: attachParams.freeTrial,
|
||||
},
|
||||
fullProduct,
|
||||
});
|
||||
}
|
||||
|
||||
const billingContext: AttachBillingContext = {
|
||||
billingVersion: BillingVersion.V1,
|
||||
fullCustomer: attachParams.customer,
|
||||
fullProducts: [fullProduct],
|
||||
featureQuantities: attachParams.optionsList,
|
||||
trialContext,
|
||||
invoiceMode,
|
||||
|
||||
// Timestamps
|
||||
currentEpochMs,
|
||||
billingCycleAnchorMs,
|
||||
resetCycleAnchorMs,
|
||||
|
||||
// Stripe context
|
||||
stripeCustomer: attachParams.stripeCus!,
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
paymentMethod: attachParams.paymentMethod ?? undefined,
|
||||
|
||||
// Attach additional context
|
||||
attachProduct: fullProduct,
|
||||
planTiming,
|
||||
checkoutMode: null,
|
||||
currentCustomerProduct,
|
||||
scheduledCustomerProduct,
|
||||
endOfCycleMs,
|
||||
};
|
||||
|
||||
return billingContext;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { setupAttachTransitionContext } from "@/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams";
|
||||
|
||||
export const setupUpgradeDowngradeBillingContext = ({
|
||||
attachParams,
|
||||
}: {
|
||||
attachParams: AttachParams;
|
||||
}) => {
|
||||
// Grab current customer product?
|
||||
const {
|
||||
customer: fullCustomer,
|
||||
products: [attachProduct],
|
||||
} = attachParams;
|
||||
|
||||
const { currentCustomerProduct } = setupAttachTransitionContext({
|
||||
fullCustomer,
|
||||
attachProduct,
|
||||
});
|
||||
|
||||
return currentCustomerProduct;
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { billingActions } from "@/internal/billing/v2/actions";
|
||||
import { attachParamsToStripeBillingContext } from "@/internal/billing/v2/actions/legacy/utils/attachParamsToStripeBillingContext";
|
||||
import { setupLegacyTransitionContext } from "@/internal/billing/v2/actions/legacy/utils/setupLegacyFeatureQuantitiesContext";
|
||||
import { billingResultToResponse } from "@/internal/billing/v2/utils/billingResult/billingResultToResponse";
|
||||
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams";
|
||||
|
||||
export const updateQuantity = async ({
|
||||
@@ -37,7 +38,7 @@ export const updateQuantity = async ({
|
||||
|
||||
// Current customer product
|
||||
const currentCustomerProduct = findActiveCustomerProductById({
|
||||
fullCustomer: attachParams.customer,
|
||||
fullCus: attachParams.customer,
|
||||
productId: fullProduct.id,
|
||||
internalEntityId: attachParams.customer.entity?.internal_id,
|
||||
});
|
||||
@@ -75,9 +76,19 @@ export const updateQuantity = async ({
|
||||
options: attachParams.optionsList,
|
||||
};
|
||||
|
||||
return await billingActions.updateSubscription({
|
||||
const res = await billingActions.updateSubscription({
|
||||
ctx,
|
||||
params,
|
||||
contextOverride: billingContextOverride,
|
||||
});
|
||||
|
||||
const billingResponse = billingResultToResponse({
|
||||
billingContext: res.billingContext,
|
||||
billingResult: res.billingResult ?? { stripe: {} },
|
||||
});
|
||||
|
||||
return {
|
||||
...res,
|
||||
billingResponse,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function migrate({
|
||||
await billingActions.updateSubscription({
|
||||
ctx,
|
||||
params: updateSubscriptionParams,
|
||||
contextOverrides: {
|
||||
contextOverride: {
|
||||
productContext: {
|
||||
customerProduct: currentCustomerProduct,
|
||||
fullProduct: newProduct,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LineItem } from "@autumn/shared";
|
||||
import type {
|
||||
LineItem,
|
||||
UpdateSubscriptionBillingContext,
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@autumn/shared";
|
||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||
|
||||
/**
|
||||
@@ -16,10 +18,12 @@ export const computeCancelLineItems = ({
|
||||
}): LineItem[] => {
|
||||
if (billingContext.cancelAction !== "cancel_immediately") return [];
|
||||
|
||||
return buildAutumnLineItems({
|
||||
const { allLineItems } = buildAutumnLineItems({
|
||||
ctx,
|
||||
newCustomerProducts: [],
|
||||
deletedCustomerProduct: billingContext.customerProduct,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
return allLineItems;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
cusProductToProcessorType,
|
||||
type FullCustomerProduct,
|
||||
type FullCusProduct,
|
||||
ProcessorType,
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
@@ -12,7 +12,7 @@ export const handleExternalPSPErrors = ({
|
||||
customerProduct,
|
||||
action,
|
||||
}: {
|
||||
customerProduct: FullCustomerProduct | null | undefined;
|
||||
customerProduct?: FullCusProduct;
|
||||
action: "attach" | "update";
|
||||
}) => {
|
||||
if (!customerProduct) return;
|
||||
|
||||
@@ -29,7 +29,13 @@ export const initFullCustomerProductFromProduct = ({
|
||||
existingRolloversConfig?: ExistingRolloversConfig;
|
||||
};
|
||||
}): FullCusProduct => {
|
||||
const { fullCustomer, fullProduct, currentEpochMs } = initContext;
|
||||
const {
|
||||
fullCustomer,
|
||||
fullProduct,
|
||||
currentEpochMs,
|
||||
existingUsagesConfig,
|
||||
existingRolloversConfig,
|
||||
} = initContext;
|
||||
|
||||
const freeTrial = fullProduct.free_trial ?? null;
|
||||
let trialEndsAt: number | undefined;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type FullCustomer,
|
||||
findMainActiveCustomerProductByGroup,
|
||||
} from "@autumn/shared";
|
||||
import { customerProductToFeaturesToCarryUsagesFor } from "@shared/utils/cusProductUtils/convertCusProduct/customerProductToFeaturesToCarryUsagesFor";
|
||||
import { cp } from "@utils/cusProductUtils/classifyCustomerProduct/cpBuilder";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages";
|
||||
@@ -37,14 +36,13 @@ export const reapplyExistingUsagesToCustomerProduct = async ({
|
||||
|
||||
if (!currentCustomerProduct) return;
|
||||
|
||||
const featuresToCarryUsagesFor = customerProductToFeaturesToCarryUsagesFor({
|
||||
cusProduct: customerProduct,
|
||||
});
|
||||
// const featuresToCarryUsagesFor = customerProductToFeaturesToCarryUsagesFor({
|
||||
// cusProduct: customerProduct,
|
||||
// });
|
||||
|
||||
const currentUsages = cusProductToExistingUsages({
|
||||
cusProduct: currentCustomerProduct,
|
||||
entityId: customerProduct.entity_id ?? undefined,
|
||||
featureIds: [], // reset all consumable features
|
||||
});
|
||||
|
||||
// Reinitialize customer entitlements with reset balance
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type AttachBodyV0,
|
||||
type AttachBranch,
|
||||
type AttachConfig,
|
||||
type AttachFunctionResponse,
|
||||
@@ -32,17 +31,15 @@ import { createStripeSub2 } from "./createStripeSub2.js";
|
||||
export const handlePaidProduct = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
body,
|
||||
config,
|
||||
branch,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
body: AttachBodyV0;
|
||||
config: AttachConfig;
|
||||
branch: AttachBranch;
|
||||
}): Promise<AttachFunctionResponse> => {
|
||||
const { logger, db } = ctx;
|
||||
const { logger } = ctx;
|
||||
|
||||
const {
|
||||
org,
|
||||
@@ -75,7 +72,7 @@ export const handlePaidProduct = async ({
|
||||
}
|
||||
|
||||
let sub: Stripe.Subscription | null = null;
|
||||
const schedule: Stripe.SubscriptionSchedule | null | undefined = null;
|
||||
|
||||
let invoice: Stripe.Invoice | undefined;
|
||||
let trialEndsAt: number | null | undefined;
|
||||
|
||||
@@ -87,7 +84,6 @@ export const handlePaidProduct = async ({
|
||||
const { billingResponse, billingResult } =
|
||||
await billingActions.legacy.attach({
|
||||
ctx,
|
||||
body,
|
||||
attachParams,
|
||||
planTiming: "immediate",
|
||||
});
|
||||
@@ -96,7 +92,7 @@ export const handlePaidProduct = async ({
|
||||
code: SuccessCode.NewProductAttached,
|
||||
message: `Successfully attached product`,
|
||||
|
||||
checkout_url: billingResponse?.payment_url,
|
||||
checkout_url: billingResponse?.payment_url ?? undefined,
|
||||
|
||||
invoice: attachParams.invoiceOnly
|
||||
? attachToInvoiceResponse({
|
||||
|
||||
@@ -205,7 +205,6 @@ export const handleScheduleFunction2 = async ({
|
||||
const { billingContext } = await billingActions.legacy.attach({
|
||||
ctx,
|
||||
attachParams,
|
||||
body,
|
||||
planTiming: "end_of_cycle",
|
||||
});
|
||||
|
||||
|
||||
@@ -16,19 +16,17 @@ export const handleUpdateQuantityFunction = async ({
|
||||
attachParams: AttachParams;
|
||||
body: AttachBodyV0;
|
||||
}) => {
|
||||
const { billingResult } = await billingActions.legacy.updateQuantity({
|
||||
const { billingResponse } = await billingActions.legacy.updateQuantity({
|
||||
ctx,
|
||||
body,
|
||||
attachParams,
|
||||
});
|
||||
|
||||
const stripeInvoice = billingResult?.stripe?.stripeInvoice;
|
||||
const invoiceMode = attachParams.invoiceOnly;
|
||||
|
||||
return AttachFunctionResponseSchema.parse({
|
||||
code: SuccessCode.FeaturesUpdated,
|
||||
message: `Successfully updated quantity for features`,
|
||||
invoice: invoiceMode && stripeInvoice ? stripeInvoice : undefined,
|
||||
invoice: billingResponse?.invoice,
|
||||
checkout_url: billingResponse?.payment_url,
|
||||
});
|
||||
|
||||
// return AttachFunctionResponseSchema.parse({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
type AttachBodyV0,
|
||||
type AttachBranch,
|
||||
type AttachConfig,
|
||||
AttachFunctionResponseSchema,
|
||||
@@ -13,14 +12,12 @@ import type { AutumnContext } from "../../../../../honoUtils/HonoEnv.js";
|
||||
export const handleUpgradeFlow = async ({
|
||||
ctx,
|
||||
attachParams,
|
||||
body,
|
||||
config,
|
||||
branch,
|
||||
fromMigration = false,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
attachParams: AttachParams;
|
||||
body: AttachBodyV0;
|
||||
config: AttachConfig;
|
||||
branch: AttachBranch;
|
||||
fromMigration?: boolean;
|
||||
@@ -259,7 +256,6 @@ export const handleUpgradeFlow = async ({
|
||||
const { billingResponse, billingResult } = await billingActions.legacy.attach(
|
||||
{
|
||||
ctx,
|
||||
body,
|
||||
attachParams,
|
||||
planTiming: "immediate",
|
||||
},
|
||||
|
||||
@@ -258,7 +258,6 @@ export const runAttachFunction = async ({
|
||||
return await handleUpgradeFlow({
|
||||
ctx,
|
||||
attachParams,
|
||||
body: attachBody,
|
||||
config,
|
||||
branch,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
AttachReplaceableSchema,
|
||||
type FullCustomerEntitlement,
|
||||
type FullEntitlement,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
usageToFeatureName,
|
||||
} from "@autumn/shared";
|
||||
@@ -28,7 +28,7 @@ export const getContUseDowngradeItems = async ({
|
||||
ent: FullEntitlement;
|
||||
prevCusEnt: FullCustomerEntitlement;
|
||||
attachParams: AttachParams;
|
||||
curItem: PreviewLineItem;
|
||||
curItem: LegacyPreviewLineItem;
|
||||
curUsage: number;
|
||||
proration?: Proration;
|
||||
logger: any;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type FullCustomerEntitlement,
|
||||
type FullEntitlement,
|
||||
getFeatureInvoiceDescription,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
shouldProrate,
|
||||
} from "@autumn/shared";
|
||||
@@ -68,7 +68,7 @@ const getContUseNewItems = async ({
|
||||
description,
|
||||
usage_model: priceToUsageModel(price),
|
||||
feature_id: ent.feature_id,
|
||||
} as PreviewLineItem;
|
||||
} as LegacyPreviewLineItem;
|
||||
} else {
|
||||
/*
|
||||
For example, free plan comes with 3 users, and usage is 3
|
||||
@@ -112,7 +112,7 @@ const getContUseNewItems = async ({
|
||||
amount,
|
||||
usage_model: priceToUsageModel(price),
|
||||
feature_id: ent.feature_id,
|
||||
} as PreviewLineItem;
|
||||
} as LegacyPreviewLineItem;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,8 +139,8 @@ export const getContUseInvoiceItems = async ({
|
||||
: [];
|
||||
|
||||
const newEnts = product.entitlements;
|
||||
const oldItems: PreviewLineItem[] = [];
|
||||
const newItems: PreviewLineItem[] = [];
|
||||
const oldItems: LegacyPreviewLineItem[] = [];
|
||||
const newItems: LegacyPreviewLineItem[] = [];
|
||||
const replaceables: AttachReplaceable[] = [];
|
||||
|
||||
for (const price of product.prices) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type FullCustomerEntitlement,
|
||||
type FullEntitlement,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
usageToFeatureName,
|
||||
} from "@autumn/shared";
|
||||
@@ -29,7 +29,7 @@ export const getContUseUpgradeItems = async ({
|
||||
ent: FullEntitlement;
|
||||
prevCusEnt: FullCustomerEntitlement;
|
||||
attachParams: AttachParams;
|
||||
curItem: PreviewLineItem;
|
||||
curItem: LegacyPreviewLineItem;
|
||||
curUsage: number;
|
||||
proration?: Proration;
|
||||
logger: Logger;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
cusProductsToCusPrices,
|
||||
type FullCustomerEntitlement,
|
||||
type FullEntitlement,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
shouldProrate,
|
||||
} from "@autumn/shared";
|
||||
@@ -36,7 +36,7 @@ export const priceToContUseItem = async ({
|
||||
attachParams: AttachParams;
|
||||
sub: Stripe.Subscription | undefined;
|
||||
logger: any;
|
||||
curItem: PreviewLineItem;
|
||||
curItem: LegacyPreviewLineItem;
|
||||
}) => {
|
||||
const { cusProducts, entities, internalEntityId, now } = attachParams;
|
||||
const product = attachParamsToProduct({ attachParams });
|
||||
@@ -140,7 +140,7 @@ export const priceToContUseItem = async ({
|
||||
oldItem: null,
|
||||
newItems: [res.newUsageItem].filter((item) =>
|
||||
notNullish(item),
|
||||
) as PreviewLineItem[],
|
||||
) as LegacyPreviewLineItem[],
|
||||
replaceables: res.replaceables,
|
||||
};
|
||||
} else {
|
||||
@@ -148,7 +148,7 @@ export const priceToContUseItem = async ({
|
||||
oldItem: res.oldItem,
|
||||
newItems: [res.newItem, res.newUsageItem].filter((item) =>
|
||||
notNullish(item),
|
||||
) as PreviewLineItem[],
|
||||
) as LegacyPreviewLineItem[],
|
||||
replaceables: res.replaceables,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
isPrepaidPrice,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
UsageModel,
|
||||
type UsagePriceConfig,
|
||||
@@ -84,7 +84,7 @@ const filterNoProratePrepaidItems = ({
|
||||
attachParams,
|
||||
curSameProduct,
|
||||
}: {
|
||||
items: PreviewLineItem[];
|
||||
items: LegacyPreviewLineItem[];
|
||||
attachParams: AttachParams;
|
||||
curSameProduct?: FullCusProduct;
|
||||
}) => {
|
||||
@@ -187,7 +187,7 @@ export const getUpgradeProductPreview = async ({
|
||||
});
|
||||
|
||||
let dueNextCycle:
|
||||
| { line_items: PreviewLineItem[]; due_at: number }
|
||||
| { line_items: LegacyPreviewLineItem[]; due_at: number }
|
||||
| undefined;
|
||||
if (!isFreeProduct(newProduct.prices)) {
|
||||
const nextCycleAt = getNextCycleAt({
|
||||
@@ -267,7 +267,7 @@ export const getUpgradeProductPreview = async ({
|
||||
|
||||
let dueToday:
|
||||
| {
|
||||
line_items: PreviewLineItem[];
|
||||
line_items: LegacyPreviewLineItem[];
|
||||
total: number;
|
||||
}
|
||||
| undefined = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
formatAmount,
|
||||
getFeatureInvoiceDescription,
|
||||
InternalError,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
priceToFeature,
|
||||
stripeToAtmnAmount,
|
||||
type UsagePriceConfig,
|
||||
@@ -40,7 +40,7 @@ export const getCurContUseItems = async ({
|
||||
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
|
||||
const curEnts = cusProductToEnts({ cusProduct: curCusProduct });
|
||||
|
||||
const items: PreviewLineItem[] = [];
|
||||
const items: LegacyPreviewLineItem[] = [];
|
||||
const now = attachParams.now || Date.now();
|
||||
|
||||
for (const item of sub.items.data) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
cusProductToPrices,
|
||||
formatAmount,
|
||||
InternalError,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import { priceToUnusedPreviewItem } from "@/internal/customers/attach/attachPreviewUtils/priceToUnusedPreviewItem.js";
|
||||
@@ -47,7 +47,7 @@ export const getItemsForCurProduct = async ({
|
||||
});
|
||||
}
|
||||
|
||||
let items: PreviewLineItem[] = [];
|
||||
let items: LegacyPreviewLineItem[] = [];
|
||||
const subItems = sub?.items.data || [];
|
||||
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
|
||||
// const anchor = sub?.billing_cycle_anchor ? sub.billing_cycle_anchor * 1000 : undefined;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
isPrepaidPrice,
|
||||
isUsagePrice,
|
||||
type Organization,
|
||||
type PreviewLineItem,
|
||||
type LegacyPreviewLineItem,
|
||||
type Price,
|
||||
priceToFeature,
|
||||
priceToInvoiceAmount,
|
||||
@@ -179,7 +179,7 @@ export const getItemsForNewProduct = async ({
|
||||
const { org, features } = attachParams;
|
||||
const now = attachParams.now || Date.now();
|
||||
|
||||
const items: PreviewLineItem[] = [];
|
||||
const items: LegacyPreviewLineItem[] = [];
|
||||
|
||||
sortPricesByType(newProduct.prices);
|
||||
|
||||
|
||||
@@ -206,11 +206,8 @@ export const handleUpdatePlan = createRoute({
|
||||
// New full product
|
||||
|
||||
await initProductInStripe({
|
||||
db,
|
||||
ctx,
|
||||
product: newFullProduct,
|
||||
org,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info("Adding task to queue to detect base variant");
|
||||
|
||||
@@ -79,15 +79,15 @@ describe(`${chalk.yellowBright(`${testCase}: Testing invoice checkout via checko
|
||||
});
|
||||
});
|
||||
|
||||
test("should have no URL returned if try to attach premium (with invoice true)", async () => {
|
||||
const res = await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
invoice: true,
|
||||
});
|
||||
// test("should have no URL returned if try to attach premium (with invoice true)", async () => {
|
||||
// const res = await autumn.attach({
|
||||
// customer_id: customerId,
|
||||
// product_id: premium.id,
|
||||
// invoice: true,
|
||||
// });
|
||||
|
||||
expect(res.url).toBeUndefined();
|
||||
});
|
||||
// expect(res.url).toBeUndefined();
|
||||
// });
|
||||
|
||||
test("should attach premium product via invoice enable immediately", async () => {
|
||||
const res = await autumn.attach({
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import type { LimitedItem, ProductV2 } from "@autumn/shared";
|
||||
import { defaultApiVersion } from "@tests/constants.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addWeeks } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { advanceTestClock } from "../../../src/utils/scriptUtils/testClockUtils.js";
|
||||
import { replaceItems } from "../utils.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
|
||||
const messagesItem = constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
}) as LimitedItem;
|
||||
|
||||
const wordsItem = constructFeatureItem({
|
||||
featureId: TestFeature.Words,
|
||||
includedUsage: 100,
|
||||
}) as LimitedItem;
|
||||
|
||||
export const free = constructProduct({
|
||||
items: [messagesItem, wordsItem],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const testCase = "migrations1";
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`)}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
let testClockId: string;
|
||||
|
||||
const curUnix = new Date().getTime();
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [free],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const { testClockId: testClockId1 } = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
testClockId = testClockId1!;
|
||||
});
|
||||
|
||||
test("should attach free product", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: free,
|
||||
stripeCli: ctx.stripeCli,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
skipSubCheck: true,
|
||||
});
|
||||
});
|
||||
|
||||
let newFree: ProductV2;
|
||||
const increaseMessagesBy = 100;
|
||||
const reduceWordsBy = 50;
|
||||
test("should update product to new version", async () => {
|
||||
newFree = structuredClone(free);
|
||||
|
||||
let newItems = replaceItems({
|
||||
items: free.items,
|
||||
featureId: TestFeature.Messages,
|
||||
newItem: constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage:
|
||||
(messagesItem.included_usage as number) + increaseMessagesBy,
|
||||
}),
|
||||
});
|
||||
|
||||
newItems = replaceItems({
|
||||
items: newItems,
|
||||
featureId: TestFeature.Words,
|
||||
newItem: constructFeatureItem({
|
||||
featureId: TestFeature.Words,
|
||||
includedUsage: (wordsItem.included_usage as number) - reduceWordsBy,
|
||||
}),
|
||||
});
|
||||
|
||||
newFree.items = newItems;
|
||||
|
||||
await autumn.products.update(free.id, {
|
||||
items: newItems,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach track usage and get correct balance", async () => {
|
||||
const wordsUsage = 25;
|
||||
const messagesUsage = 20;
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
value: wordsUsage,
|
||||
feature_id: TestFeature.Words,
|
||||
});
|
||||
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
value: messagesUsage,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addWeeks(Date.now(), 1).getTime(),
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
let customer = await autumn.customers.get(customerId);
|
||||
|
||||
await autumn.migrate({
|
||||
from_product_id: free.id,
|
||||
to_product_id: newFree.id,
|
||||
from_version: 1,
|
||||
to_version: 2,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 4000));
|
||||
|
||||
// 1. Get features
|
||||
customer = await autumn.customers.get(customerId);
|
||||
|
||||
await runMigrationTest({
|
||||
autumn,
|
||||
stripeCli: ctx.stripeCli,
|
||||
customerId,
|
||||
fromProduct: free,
|
||||
toProduct: newFree,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
usage: [
|
||||
{
|
||||
featureId: TestFeature.Words,
|
||||
value: wordsUsage,
|
||||
},
|
||||
{
|
||||
featureId: TestFeature.Messages,
|
||||
value: messagesUsage,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
|
||||
});
|
||||
|
||||
newPro.items = newItems;
|
||||
newPro.version = 2;
|
||||
await autumn.products.update(pro.id, {
|
||||
items: newItems,
|
||||
});
|
||||
@@ -112,15 +113,6 @@ describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage pro
|
||||
advanceTo: addWeeks(Date.now(), 1).getTime(),
|
||||
});
|
||||
|
||||
await autumn.migrate({
|
||||
from_product_id: pro.id,
|
||||
to_product_id: newPro.id,
|
||||
from_version: 1,
|
||||
to_version: 2,
|
||||
});
|
||||
|
||||
await timeout(4000);
|
||||
|
||||
await runMigrationTest({
|
||||
autumn,
|
||||
stripeCli: ctx.stripeCli,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
BillingInterval,
|
||||
ProductItemInterval,
|
||||
type ProductV2,
|
||||
} from "@autumn/shared";
|
||||
import { defaultApiVersion } from "@tests/constants.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { timeout } from "@tests/utils/genUtils.js";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addDays } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { replaceItems } from "../utils.js";
|
||||
import { runMigrationTest } from "./runMigrationTest.js";
|
||||
|
||||
const wordsItem = constructArrearItem({
|
||||
featureId: TestFeature.Words,
|
||||
});
|
||||
|
||||
export const pro = constructProduct({
|
||||
items: [wordsItem],
|
||||
type: "pro",
|
||||
isDefault: false,
|
||||
trial: true,
|
||||
});
|
||||
|
||||
const testCase = "migrations3";
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with trial`)}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
|
||||
let testClockId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const { testClockId: testClockId1 } = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
testClockId = testClockId1!;
|
||||
});
|
||||
|
||||
test("should attach free product", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: pro,
|
||||
stripeCli: ctx.stripeCli,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
let newPro: ProductV2;
|
||||
const increaseWordsBy = 1500;
|
||||
test("should update product to new version", async () => {
|
||||
newPro = structuredClone(pro);
|
||||
|
||||
let newItems = replaceItems({
|
||||
items: pro.items,
|
||||
featureId: TestFeature.Words,
|
||||
newItem: constructArrearItem({
|
||||
featureId: TestFeature.Words,
|
||||
includedUsage: (wordsItem.included_usage as number) + increaseWordsBy,
|
||||
}),
|
||||
});
|
||||
|
||||
newItems = replaceItems({
|
||||
items: newItems,
|
||||
interval: BillingInterval.Month,
|
||||
newItem: {
|
||||
price: 50,
|
||||
interval: ProductItemInterval.Month,
|
||||
},
|
||||
});
|
||||
|
||||
newPro.items = newItems;
|
||||
newPro.version = 2;
|
||||
await autumn.products.update(pro.id, {
|
||||
items: newItems,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach track usage and get correct balance", async () => {
|
||||
const wordsUsage = 120000;
|
||||
await timeout(2000);
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
value: wordsUsage,
|
||||
feature_id: TestFeature.Words,
|
||||
});
|
||||
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addDays(Date.now(), 4).getTime(),
|
||||
});
|
||||
|
||||
// await timeout(5000);
|
||||
|
||||
await runMigrationTest({
|
||||
autumn,
|
||||
stripeCli: ctx.stripeCli,
|
||||
customerId,
|
||||
fromProduct: pro,
|
||||
toProduct: newPro,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
usage: [
|
||||
{
|
||||
featureId: TestFeature.Words,
|
||||
value: wordsUsage,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
AttachErrCode,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
|
||||
import { createProducts } from "@tests/utils/productUtils.js";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { addPrefixToProducts } from "@tests/utils/testProductUtils/testProductUtils.js";
|
||||
import chalk from "chalk";
|
||||
import { addWeeks } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
|
||||
const testCase = "updateQuantity1";
|
||||
|
||||
export const pro = constructProduct({
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Users,
|
||||
price: 12,
|
||||
billingUnits: 1,
|
||||
}),
|
||||
],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing upgrades with prepaid single use`)}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
let testClockId: string;
|
||||
let db: DrizzleCli, org: Organization, env: AppEnv;
|
||||
let stripeCli: Stripe;
|
||||
|
||||
let curUnix = new Date().getTime();
|
||||
const numUsers = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
|
||||
const { testClockId: testClockId1 } = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
addPrefixToProducts({
|
||||
products: [pro],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await createProducts({
|
||||
autumn,
|
||||
products: [pro],
|
||||
db,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
testClockId = testClockId1!;
|
||||
});
|
||||
|
||||
const proOpts = [
|
||||
{
|
||||
feature_id: TestFeature.Users,
|
||||
quantity: 2,
|
||||
},
|
||||
];
|
||||
|
||||
test("should attach pro product (arrear prorated)", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: pro,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
options: proOpts,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if try to attach same options", async () => {
|
||||
await expectAutumnError({
|
||||
errCode: AttachErrCode.ProductAlreadyAttached,
|
||||
func: async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: proOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const updatedOpts = [
|
||||
{
|
||||
feature_id: TestFeature.Users,
|
||||
quantity: 4,
|
||||
},
|
||||
];
|
||||
|
||||
test("should update quantity to 4 users and have usage stay the same", async () => {
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
value: 2,
|
||||
});
|
||||
await timeout(3000);
|
||||
|
||||
curUnix = await advanceTestClock({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addWeeks(curUnix, 1).getTime(),
|
||||
waitForSeconds: 30,
|
||||
});
|
||||
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: pro,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
options: updatedOpts,
|
||||
usage: [
|
||||
{
|
||||
featureId: TestFeature.Users,
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
waitForInvoice: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, AttachPreview } from "@autumn/shared";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
@@ -63,7 +63,7 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: allow_promotion_codes")}
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(20);
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
// 2. Attempt attach - should return payment_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
@@ -146,7 +146,7 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: discounted checkout")}`,
|
||||
customer_id: customerId,
|
||||
product_id: discountedPro.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(15);
|
||||
expect(preview.total).toBe(15);
|
||||
|
||||
// 2. Attempt attach - should return payment_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, type AttachPreview, ms } from "@autumn/shared";
|
||||
import { type ApiCustomerV3, ms } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
@@ -63,7 +63,7 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial card required")}`,
|
||||
customer_id: customerId,
|
||||
product_id: proTrial.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(0);
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// 2. Attempt attach - should return payment_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
@@ -147,7 +147,7 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout: trial subscription_data"
|
||||
customer_id: customerId,
|
||||
product_id: proTrial.id,
|
||||
});
|
||||
expect((preview as AttachPreview).due_today.total).toBe(0);
|
||||
expect(preview.total).toBe(0);
|
||||
|
||||
// 2. Attempt attach - should return payment_url
|
||||
const result = await autumnV1.billing.attach({
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import {
|
||||
expectProductActive,
|
||||
expectProductCanceling,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
@@ -93,12 +97,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: basic renew with same q
|
||||
// Verify customer is canceled but still has access until period end
|
||||
const customerCanceled =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const canceledProduct = customerCanceled.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (!canceledProduct?.canceled) {
|
||||
throw new Error("Expected product to be in canceled state");
|
||||
}
|
||||
await expectProductCanceling({
|
||||
customer: customerCanceled,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
// V1 attach to same product (renew flow)
|
||||
await autumnV1.attach({
|
||||
@@ -114,14 +116,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: basic renew with same q
|
||||
|
||||
// Verify customer is renewed (no longer canceled)
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be canceled
|
||||
const renewedProduct = customerAfter.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (renewedProduct?.canceled) {
|
||||
throw new Error("Expected product to NOT be canceled after renew");
|
||||
}
|
||||
await expectProductActive({
|
||||
customer: customerAfter,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
@@ -224,12 +222,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: renew preserves usage")
|
||||
// Verify canceled
|
||||
const customerCanceled =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const canceledProduct = customerCanceled.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (!canceledProduct?.canceled) {
|
||||
throw new Error("Expected product to be in canceled state");
|
||||
}
|
||||
await expectProductCanceling({
|
||||
customer: customerCanceled,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
// V1 attach to same product (renew flow)
|
||||
await autumnV1.attach({
|
||||
@@ -245,14 +241,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: renew preserves usage")
|
||||
|
||||
// Verify customer is renewed with usage preserved
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be canceled
|
||||
const renewedProduct = customerAfter.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (renewedProduct?.canceled) {
|
||||
throw new Error("Expected product to NOT be canceled after renew");
|
||||
}
|
||||
await expectProductActive({
|
||||
customer: customerAfter,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
// Usage should be preserved after renew
|
||||
expectCustomerFeatureCorrect({
|
||||
@@ -339,14 +331,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: renew with increased qu
|
||||
|
||||
// Verify customer is renewed with new quantity
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be canceled
|
||||
const renewedProduct = customerAfter.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (renewedProduct?.canceled) {
|
||||
throw new Error("Expected product to NOT be canceled after renew");
|
||||
}
|
||||
await expectProductActive({
|
||||
customer: customerAfter,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
@@ -438,14 +426,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: renew with decreased qu
|
||||
|
||||
// Verify customer is renewed with new quantity
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be canceled
|
||||
const renewedProduct = customerAfter.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (renewedProduct?.canceled) {
|
||||
throw new Error("Expected product to NOT be canceled after renew");
|
||||
}
|
||||
await expectProductActive({
|
||||
customer: customerAfter,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
@@ -533,14 +517,10 @@ test.concurrent(`${chalk.yellowBright("v2→v1 uncancel: single billing unit (us
|
||||
|
||||
// Verify customer is renewed
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Product should no longer be canceled
|
||||
const renewedProduct = customerAfter.products.find((p) =>
|
||||
p.id.includes(pro.id),
|
||||
);
|
||||
if (renewedProduct?.canceled) {
|
||||
throw new Error("Expected product to NOT be canceled after renew");
|
||||
}
|
||||
await expectProductActive({
|
||||
customer: customerAfter,
|
||||
productId: `${pro.id}_${customerId}`,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
|
||||
105
server/tests/integration/billing/cron/void-invoice-cron.test.ts
Normal file
105
server/tests/integration/billing/cron/void-invoice-cron.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Void Invoice Cron Tests
|
||||
*
|
||||
* Tests that the handleVoidInvoiceCron function correctly voids open invoices
|
||||
* from failed payment attempts (e.g., 3DS required but not completed).
|
||||
*
|
||||
* Migrated from: invoice-action-required2.test.ts
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { timeout } from "@tests/utils/genUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { handleVoidInvoiceCron } from "@/cron/invoiceCron/runInvoiceCron";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Void open invoice from failed upgrade
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with success PM
|
||||
* - Swap to authenticate PM
|
||||
* - Upgrade to premium → open invoice (checkout_url returned)
|
||||
* - Retrieve Stripe invoice, get autumn metadata
|
||||
* - Call handleVoidInvoiceCron → invoice voided
|
||||
* - Verify customer reflects voided invoice
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("void-invoice-cron 1: void open invoice from failed upgrade")}`, async () => {
|
||||
const customerId = "void-invoice-cron-upgrade";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 100,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attachPaymentMethod({ type: "authenticate" }),
|
||||
],
|
||||
});
|
||||
|
||||
// Upgrade to premium — should fail with checkout_url
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
// Verify open invoice on customer
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customer.invoices?.[0].status).toBe("open");
|
||||
|
||||
// Get the Stripe invoice and its autumn metadata
|
||||
const stripeInvoices = await ctx.stripeCli.invoices.list({
|
||||
customer: customer.stripe_id!,
|
||||
});
|
||||
const latestInvoice = stripeInvoices.data[0];
|
||||
|
||||
expect(latestInvoice.metadata?.autumn_metadata_id).toBeDefined();
|
||||
|
||||
const metadata = await MetadataService.get({
|
||||
db: ctx.db,
|
||||
id: latestInvoice.metadata?.autumn_metadata_id ?? "",
|
||||
});
|
||||
|
||||
expect(metadata).toBeDefined();
|
||||
|
||||
// Run the void invoice cron handler
|
||||
await handleVoidInvoiceCron({
|
||||
metadata: metadata!,
|
||||
ctx: {
|
||||
db: ctx.db,
|
||||
logger: ctx.logger,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the Stripe invoice is now voided
|
||||
const voidedInvoice = await ctx.stripeCli.invoices.retrieve(latestInvoice.id);
|
||||
expect(voidedInvoice.status).toBe("void");
|
||||
|
||||
// Wait for cache to update, then verify customer reflects voided status
|
||||
await timeout(3000);
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customerAfter.invoices?.[0].status).toBe("void");
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Attach New Billing Subscription Tests (Legacy Migration)
|
||||
*
|
||||
* Tests for the `new_billing_subscription` flag on attach, which creates
|
||||
* a separate Stripe subscription instead of merging into the existing one.
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/integration/billing/new-billing-subscription/new-billing-subscription1.test.ts
|
||||
*
|
||||
* Key behaviors tested:
|
||||
* - Add-on with new_billing_subscription creates separate sub mid-cycle
|
||||
* - Attaching same add-on again creates a third sub
|
||||
* - Entities with new_billing_subscription get separate subs
|
||||
* - Upgrading main customer doesn't affect entity's separate sub
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, CusExpand } from "@autumn/shared";
|
||||
import { expectSubCount } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Paid add-on with new_billing_subscription mid-cycle, then attach again
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro product to customer
|
||||
* - Advance clock 2 weeks (mid-cycle)
|
||||
* - Attach paid add-on with new_billing_subscription → creates 2nd sub
|
||||
* - Attach same add-on again with new_billing_subscription → creates 3rd sub
|
||||
*
|
||||
* Expected:
|
||||
* - After first add-on: 2 subs, 2 invoices, add-on product attached
|
||||
* - After second add-on: 3 subs, 3 invoices, add-on quantity = 2
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach: paid add-on with new_billing_subscription mid-cycle")}`, async () => {
|
||||
const customerId = "new-billing-sub-addon";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 300 })],
|
||||
});
|
||||
|
||||
const addOn = products.recurringAddOn({
|
||||
id: "addon",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.advanceTestClock({ weeks: 2 }),
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
newBillingSubscription: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// After first add-on attach: 2 subs
|
||||
await expectSubCount({ ctx, customerId, count: 2 });
|
||||
|
||||
const customer1 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({ customer: customer1, productId: addOn.id });
|
||||
expect(customer1.invoices.length).toBe(2);
|
||||
expect(customer1.invoices[0].total).toBe(10);
|
||||
|
||||
// Attach same add-on again → 3 subs
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
await expectSubCount({ ctx, customerId, count: 3 });
|
||||
|
||||
const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const addOnProduct = customer2.products.find((p) => p.id === addOn.id);
|
||||
expect(addOnProduct?.quantity).toBe(2);
|
||||
expect(customer2.invoices?.length).toBe(3);
|
||||
expect(customer2.invoices?.[0].total).toBe(10);
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Entities with new_billing_subscription (separate subs per entity)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer with pro attached (customer-level)
|
||||
* - 2 entities
|
||||
* - Attach premium to entity 1 with new_billing_subscription → 2 subs
|
||||
* - Attach premium to entity 2 with new_billing_subscription → 3 subs
|
||||
*
|
||||
* Expected:
|
||||
* - Each entity premium is on a separate sub
|
||||
* - Customer pro + entity 1 premium + entity 2 premium = 3 subs
|
||||
* - All products active
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach: entities with new_billing_subscription (separate subs)")}`, async () => {
|
||||
const customerId = "new-billing-sub-entities";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 300 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
newBillingSubscription: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// After entity 1 attach: 2 subs
|
||||
await expectSubCount({ ctx, customerId, count: 2 });
|
||||
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: premium.id });
|
||||
|
||||
// Attach premium to entity 2 → 3 subs
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
product_id: premium.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
await expectSubCount({ ctx, customerId, count: 3 });
|
||||
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
|
||||
// Verify final state
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
expand: [CusExpand.Invoices],
|
||||
});
|
||||
|
||||
const customerPro = customer.products.find((p) => p.id === pro.id);
|
||||
expect(customerPro).toBeDefined();
|
||||
expect(customerPro?.status).toBe("active");
|
||||
|
||||
const entity1Final = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
const e1Premium = entity1Final.products?.find(
|
||||
(p: { id?: string }) => p.id === premium.id,
|
||||
);
|
||||
expect(e1Premium).toBeDefined();
|
||||
expect(e1Premium!.status).toBe("active");
|
||||
|
||||
const entity2Final = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
const e2Premium = entity2Final.products?.find(
|
||||
(p: { id?: string }) => p.id === premium.id,
|
||||
);
|
||||
expect(e2Premium).toBeDefined();
|
||||
expect(e2Premium!.status).toBe("active");
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Customer upgrade doesn't affect entity's separate sub
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer with pro + entity 1 with premium (on separate sub)
|
||||
* - Upgrade customer from pro to premium
|
||||
*
|
||||
* Expected:
|
||||
* - Customer pro is replaced by premium
|
||||
* - Entity 1 premium remains on its separate sub
|
||||
* - Still 2 subs total (not 3)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach: customer upgrade doesn't affect entity separate sub")}`, async () => {
|
||||
const customerId = "new-billing-sub-upgrade";
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [items.monthlyMessages({ includedUsage: 300 })],
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [items.monthlyMessages({ includedUsage: 1000 })],
|
||||
});
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
newBillingSubscription: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify initial: customer pro + entity premium = 2 subs
|
||||
await expectSubCount({ ctx, customerId, count: 2 });
|
||||
|
||||
const customerBefore = await autumnV1.customers.get(customerId);
|
||||
expectProductAttached({ customer: customerBefore, productId: pro.id });
|
||||
|
||||
const entityBefore = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entityBefore, productId: premium.id });
|
||||
|
||||
// Upgrade customer from pro → premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({ customer: customerAfter, productId: premium.id });
|
||||
|
||||
// Pro should be gone from customer-level products
|
||||
const proProduct = customerAfter.products.find(
|
||||
(p) => p.id === pro.id && !p.entity_id,
|
||||
);
|
||||
expect(proProduct).toBeUndefined();
|
||||
|
||||
// Still 2 subs (customer premium + entity premium on separate sub)
|
||||
await expectSubCount({ ctx, customerId, count: 2 });
|
||||
|
||||
// Entity should still have premium on its separate sub
|
||||
const entityAfter = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
const entityProducts = entityAfter.products!;
|
||||
expect(entityProducts.length).toBe(1);
|
||||
const entityPremium = entityProducts.find(
|
||||
(p: { id?: string }) => p.id === premium.id,
|
||||
);
|
||||
expect(entityPremium).toBeDefined();
|
||||
expect(entityPremium!.status).toBe("active");
|
||||
|
||||
const invoices = customerAfter.invoices;
|
||||
expect(invoices).toBeDefined();
|
||||
expect(invoices!.length).toBeGreaterThanOrEqual(1);
|
||||
}, 120000);
|
||||
@@ -15,13 +15,19 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
AttachErrCode,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectProductItemCorrect } from "@tests/integration/billing/utils/expectProductItemCorrect";
|
||||
import { calculateProratedCharge } from "@tests/integration/billing/utils/stripeSubscriptionUtils";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
@@ -645,3 +651,88 @@ test.concurrent(`${chalk.yellowBright("attach: quantity decrease with OnDecrease
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 6: Prepaid users (billingUnits: 1) - upgrade quantity mid-cycle, usage preserved
|
||||
// (Migrated from updateQuantity/updateQuantity1.test.ts)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach prepaid users with quantity 2 (billingUnits: 1, $12/user)
|
||||
* - Error when re-attaching with same options
|
||||
* - Track 2 users usage
|
||||
* - Advance test clock 1 week (mid-cycle)
|
||||
* - Upgrade quantity to 4
|
||||
*
|
||||
* Expected Result:
|
||||
* - Re-attach with same options throws ProductAlreadyAttached
|
||||
* - After upgrade: balance = 4 - 2 = 2, usage stays at 2
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("attach: prepaid users upgrade quantity mid-cycle, usage preserved")}`, async () => {
|
||||
const customerId = "attach-prepaid-users-qty-upgrade";
|
||||
const usage = 2;
|
||||
|
||||
const prepaidItem = items.prepaidUsers({
|
||||
billingUnits: 1,
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Users, quantity: 2 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Re-attaching with same options should throw
|
||||
await expectAutumnError({
|
||||
errCode: AttachErrCode.ProductAlreadyAttached,
|
||||
func: async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Users, quantity: 2 }],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Track 2 users, advance 1 week, upgrade to quantity 4
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Users,
|
||||
value: usage,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Users, quantity: 4 }],
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Usage should stay the same after quantity upgrade
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Users,
|
||||
includedUsage: 4,
|
||||
balance: 4 - usage,
|
||||
usage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Legacy Downgrade Merged Tests — Clock Advancement
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/downgrade/mergedDowngrade2.test.ts (Test 2)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade4.test.ts (Test 4)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade9.test.ts (Test 8)
|
||||
*
|
||||
* Tests V1 attach downgrade behavior with test clock advancement:
|
||||
* - Downgrade to free + pro, advance clock, verify activation, then upgrade
|
||||
* - Mixed annual + monthly intervals, advance clock verifies only monthly activates
|
||||
* - Annual + monthly downgrade, advance clock, then upgrade post-activation
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Downgrade to free + downgrade to pro, advance clock, then upgrade
|
||||
// (from mergedDowngrade2)
|
||||
//
|
||||
// Ops: Premium(ent1), Free(ent1→sched), Premium(ent2), Pro(ent2→sched)
|
||||
// Advance clock → ent1=Free(active), ent2=Pro(active)
|
||||
// Then upgrade ent1 back to Premium
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 2: downgrade to free + pro, advance clock, upgrade")}`, async () => {
|
||||
const customerId = "legacy-downgrade-2";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const wordsConsumable = items.consumableWords();
|
||||
const free = products.base({ id: "free", items: [wordsItem] });
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [wordsConsumable],
|
||||
});
|
||||
const pro = products.pro({ id: "pro", items: [wordsConsumable] });
|
||||
|
||||
// Setup: Premium(ent1), Free(ent1→sched), Premium(ent2), Pro(ent2→sched), advance clock
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, free] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({ productId: free.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
s.advanceToNextInvoice(),
|
||||
],
|
||||
});
|
||||
|
||||
// After advancement: entity 1 = Free (active), entity 2 = Pro (active)
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Upgrade entity 1 from Free back to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Mixed annual + monthly, downgrade monthly entity, advance clock
|
||||
// (from mergedDowngrade4)
|
||||
//
|
||||
// Ops: PremiumAnnual(ent1), Premium(ent2), Pro(ent2→sched)
|
||||
// Advance clock → ent1=PremiumAnnual(active), ent2=Pro(active)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 4: annual + monthly, advance clock activates schedule")}`, async () => {
|
||||
const customerId = "legacy-downgrade-4";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premiumAnnualItem = items.annualPrice({ price: 500 });
|
||||
const premiumAnnualProduct = products.base({
|
||||
id: "premiumAnnual",
|
||||
items: [wordsItem, premiumAnnualItem],
|
||||
});
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
// Setup: PremiumAnnual(ent1), Premium(ent2), Pro(ent2→sched), advance clock
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, premiumAnnualProduct] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premiumAnnualProduct.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
s.advanceToNextInvoice(),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: PremiumAnnual still active (annual hasn't ended)
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premiumAnnualProduct.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premiumAnnualProduct.group)
|
||||
.length,
|
||||
).toBe(1);
|
||||
|
||||
// Entity 2: Pro active (monthly schedule activated)
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 8: Mixed annual + monthly downgrade, advance clock, then upgrade
|
||||
// (from mergedDowngrade9)
|
||||
//
|
||||
// Ops: PremiumAnnual(ent1), Premium(ent2), Pro(ent1→sched), Pro(ent2→sched)
|
||||
// Advance clock → ent1=PremiumAnnual+Pro(sched), ent2=Pro(active)
|
||||
// Then upgrade ent2 back to Premium
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 8: annual + monthly downgrade, advance clock, upgrade")}`, async () => {
|
||||
const customerId = "legacy-downgrade-9";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premiumAnnualItem = items.annualPrice({ price: 500 });
|
||||
const premiumAnnual = products.base({
|
||||
id: "premiumAnnual",
|
||||
items: [wordsItem, premiumAnnualItem],
|
||||
});
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
// Setup: PremiumAnnual(ent1), Premium(ent2), Pro(ent1→sched), Pro(ent2→sched), advance clock
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, premiumAnnual] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premiumAnnual.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
s.advanceToNextInvoice(),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: PremiumAnnual still active + Pro still scheduled (annual hasn't ended)
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premiumAnnual.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(2);
|
||||
|
||||
// Entity 2: Pro active (monthly schedule activated)
|
||||
let entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
// Upgrade entity 2 back to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,527 @@
|
||||
/**
|
||||
* Legacy Downgrade Merged Tests — Schedule/Renew Behavior
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/downgrade/mergedDowngrade1.test.ts (Test 1)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade3.test.ts (Test 3)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade5.test.ts (Test 5)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade6.test.ts (Test 6)
|
||||
* - server/tests/merged/downgrade/mergedDowngrade8.test.ts (Test 7)
|
||||
*
|
||||
* Tests V1 attach downgrade/schedule behavior for entity-level merged subscriptions:
|
||||
* - Scheduled downgrades (Premium → Pro) across entities
|
||||
* - Downgrade to free product
|
||||
* - Renewing after a scheduled downgrade (cancelling the schedule)
|
||||
* - Mixed billing intervals (annual + monthly) with downgrades
|
||||
* - Changing scheduled downgrades (Growth → Free → Pro → Premium → Free)
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Downgrade 2 entities from Premium → Pro, then renew to Premium
|
||||
// (from mergedDowngrade1)
|
||||
//
|
||||
// Ops: Premium(ent1), Premium(ent2), Pro(ent1→sched), Pro(ent2→sched),
|
||||
// Premium(ent1→renew), Premium(ent2→renew)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 1: downgrade 2 entities then renew")}`, async () => {
|
||||
const customerId = "legacy-downgrade-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Downgrade entity 1 to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(2);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Downgrade entity 2 to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
let entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Renew entity 1 back to Premium (cancels scheduled downgrade)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: premium.id });
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Renew entity 2 back to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Pro on 2 entities, downgrade ent1 to free, upgrade ent2 to premium
|
||||
// (from mergedDowngrade3)
|
||||
//
|
||||
// Ops: Pro(ent1), Pro(ent2), Free(ent1→sched), Premium(ent2→upgrade)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 3: pro entities, downgrade to free + upgrade to premium")}`, async () => {
|
||||
const customerId = "legacy-downgrade-3";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const wordsConsumable = items.consumableWords();
|
||||
const free = products.base({ id: "free", items: [wordsItem] });
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [wordsConsumable],
|
||||
});
|
||||
const pro = products.pro({ id: "pro", items: [wordsConsumable] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, free] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Downgrade to Free (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: pro.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(2);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Entity 2: Upgrade to Premium (immediate)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 5: Downgrade both entities to free, then change ent2 to pro
|
||||
// (from mergedDowngrade5)
|
||||
//
|
||||
// Ops: Premium(ent1), Premium(ent2), Free(ent1→sched), Free(ent2→sched),
|
||||
// Pro(ent2→replaces free schedule)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 5: downgrade to free, then change schedule to pro")}`, async () => {
|
||||
const customerId = "legacy-downgrade-5";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const free = products.base({ id: "free", items: [wordsItem] });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, free, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Downgrade to Free (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
// Entity 2: Downgrade to Free (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
let entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
shouldBeCanceled: true,
|
||||
});
|
||||
|
||||
// Entity 2: Change schedule from Free to Pro
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 6: Multiple schedule changes on same entity
|
||||
// (from mergedDowngrade6)
|
||||
//
|
||||
// Ops: Growth(ent1), Growth(ent2), Free(ent1→sched), Pro(ent1→replaces),
|
||||
// Premium(ent1→replaces), Free(ent1→replaces back)
|
||||
// Tests that changing the scheduled product replaces the previous schedule
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 6: multiple schedule changes on same entity")}`, async () => {
|
||||
const customerId = "legacy-downgrade-6";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const free = products.base({ id: "free", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const growth = products.growth({ id: "growth", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, free, premium, growth] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: growth.id, entityIndex: 0 }),
|
||||
s.attach({ productId: growth.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Downgrade to Free (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: growth.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
// Entity 1: Change schedule to Pro
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: growth.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
// Entity 1: Change schedule to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: growth.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
// Entity 1: Change schedule back to Free
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: growth.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: free.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 7: Downgrade mixed annual + monthly, then renew both
|
||||
// (from mergedDowngrade8)
|
||||
//
|
||||
// Ops: PremiumAnnual(ent1), Premium(ent2), Pro(ent1→sched), Pro(ent2→sched),
|
||||
// PremiumAnnual(ent1→renew), Premium(ent2→renew)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-downgrade 7: mixed annual + monthly downgrade then renew")}`, async () => {
|
||||
const customerId = "legacy-downgrade-8";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premiumAnnualItem = items.annualPrice({ price: 500 });
|
||||
const premiumAnnual = products.base({
|
||||
id: "premiumAnnual",
|
||||
items: [wordsItem, premiumAnnualItem],
|
||||
});
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, premiumAnnual] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premiumAnnual.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Downgrade to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({ customer: entity1, productId: premiumAnnual.id });
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Entity 2: Downgrade to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
let entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({ customer: entity2, productId: premium.id });
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Entity 1: Renew to PremiumAnnual (cancels schedule)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premiumAnnual.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premiumAnnual.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity1.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Entity 2: Renew to Premium (cancels schedule)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Active,
|
||||
});
|
||||
expect(
|
||||
entity2.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(1);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Legacy Attach V1 Group - Merged Subscription Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/group/mergedGroup1.test.ts (products from different groups)
|
||||
* - server/tests/merged/group/mergedGroup2.test.ts (downgrade within group, other group unaffected)
|
||||
*
|
||||
* Tests V1 attach behavior for products in different groups.
|
||||
* Products in different groups don't compete — attaching g1Premium does NOT replace g2Pro.
|
||||
* Products in the same group DO compete — attaching g1Premium replaces g1Pro.
|
||||
*/
|
||||
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3, CusProductStatus } from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Products from different groups — upgrade in g1, g2 unaffected
|
||||
// (from mergedGroup1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Group 1: g1Pro ($20), g1Premium ($50)
|
||||
// - Group 2: g2Pro ($20), g2Premium ($50)
|
||||
// - Attach g1Pro → attach g2Pro → upgrade g1Pro to g1Premium → downgrade g1 to g1Pro
|
||||
//
|
||||
// Expected at each step:
|
||||
// 1. g1Pro active
|
||||
// 2. g1Pro active + g2Pro active (different groups, no conflict)
|
||||
// 3. g1Premium active + g2Pro active (g1Pro replaced within group 1)
|
||||
// 4. g1Premium active + g2Pro active + g1Pro scheduled (downgrade within group 1)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-group-merged 1: upgrade in one group, other group unaffected")}`, async () => {
|
||||
const customerId = "legacy-group-merged-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const g1Pro = products.base({
|
||||
id: "g1-pro",
|
||||
items: [wordsItem, proPrice],
|
||||
group: "group-1",
|
||||
});
|
||||
const g1Premium = products.base({
|
||||
id: "g1-premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
group: "group-1",
|
||||
});
|
||||
const g2Pro = products.base({
|
||||
id: "g2-pro",
|
||||
items: [wordsItem, proPrice],
|
||||
group: "group-2",
|
||||
});
|
||||
const g2Premium = products.base({
|
||||
id: "g2-premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
group: "group-2",
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [g1Pro, g1Premium, g2Pro, g2Premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: g1Pro.id })],
|
||||
});
|
||||
|
||||
// Verify g1Pro is active
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Step 2: Attach g2Pro (different group, no conflict)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: g2Pro.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g2Pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Step 3: Upgrade g1Pro to g1Premium (within group 1)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: g1Premium.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g2Pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Step 4: Downgrade g1Premium to g1Pro (scheduled within group 1)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: g1Pro.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g2Pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Downgrade in one group, other group unaffected
|
||||
// (from mergedGroup2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Group 1: g1Pro ($20), g1Premium ($50)
|
||||
// - Group 2: g2Pro ($20), g2Premium ($50)
|
||||
// - Attach g1Premium → attach g2Premium → downgrade g1Premium to g1Pro (scheduled)
|
||||
//
|
||||
// Expected:
|
||||
// - g1Premium active + g2Premium active + g1Pro scheduled
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-group-merged 2: downgrade in one group, other group unaffected")}`, async () => {
|
||||
const customerId = "legacy-group-merged-2";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const g1Pro = products.base({
|
||||
id: "g1-pro",
|
||||
items: [wordsItem, proPrice],
|
||||
group: "group-1",
|
||||
});
|
||||
const g1Premium = products.base({
|
||||
id: "g1-premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
group: "group-1",
|
||||
});
|
||||
const g2Pro = products.base({
|
||||
id: "g2-pro",
|
||||
items: [wordsItem, proPrice],
|
||||
group: "group-2",
|
||||
});
|
||||
const g2Premium = products.base({
|
||||
id: "g2-premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
group: "group-2",
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [g1Pro, g2Pro, g1Premium, g2Premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: g1Premium.id })],
|
||||
});
|
||||
|
||||
// Attach g2Premium (different group)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: g2Premium.id,
|
||||
});
|
||||
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g2Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Downgrade g1Premium to g1Pro (scheduled, within group 1)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: g1Pro.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g2Premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: g1Pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Legacy Attach V1 Invoice Mode Tests (Finalized, Non-Deferred)
|
||||
*
|
||||
* Tests that V1 attach() with `invoice: true` (finalize_invoice defaults to true)
|
||||
* returns a checkout_url (hosted invoice URL) and defers product activation
|
||||
* until the invoice is paid.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. New subscription (non-merged)
|
||||
* 2. New subscription (merged / add-on)
|
||||
* 3. Upgrade (pro → premium)
|
||||
* 4. Update quantity (prepaid increase)
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, SuccessCode } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import {
|
||||
expectProductAttached,
|
||||
expectProductNotAttached,
|
||||
} from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: New subscription (non-merged) - invoice mode
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with invoice: true (finalize_invoice defaults to true)
|
||||
* - Returns checkout_url (hosted invoice URL), product NOT active
|
||||
* - Complete checkout → product active
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-inv-mode 1: new subscription")}`, async () => {
|
||||
const customerId = "legacy-inv-mode-new";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
invoice: true,
|
||||
});
|
||||
|
||||
expect(res.code).toBe(SuccessCode.CheckoutCreated);
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Product should NOT be attached yet
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined();
|
||||
|
||||
await completeInvoiceCheckout({ url: res.checkout_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: pro,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: New subscription (merged / add-on) - invoice mode
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro normally (no invoice mode)
|
||||
* - Attach monthly add-on with invoice: true → checkout_url
|
||||
* - Add-on NOT attached until payment completes
|
||||
* - Complete checkout → both products attached, merged sub correct
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-inv-mode 2: merged add-on")}`, async () => {
|
||||
const customerId = "legacy-inv-mode-merged";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
||||
const addOnPriceItem = items.monthlyPrice({ price: 10 });
|
||||
const addOn = products.base({
|
||||
id: "monthly-addon",
|
||||
isAddOn: true,
|
||||
items: [addOnMessagesItem, addOnPriceItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
invoice: true,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Pro should still be attached, add-on should NOT be attached
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerBefore as any,
|
||||
product: pro,
|
||||
});
|
||||
expectProductNotAttached({
|
||||
customer: customerBefore as any,
|
||||
product: addOn,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
await completeInvoiceCheckout({ url: res.checkout_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: pro,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: addOn,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Upgrade (pro → premium) - invoice mode
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro normally
|
||||
* - Upgrade to premium with invoice: true → checkout_url
|
||||
* - Still on pro until payment completes
|
||||
* - Complete checkout → premium active, invoice paid
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-inv-mode 3: upgrade")}`, async () => {
|
||||
const customerId = "legacy-inv-mode-upgrade";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 500,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
invoice: true,
|
||||
enable_product_immediately: true,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: premium,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Invoice should be paid after checkout
|
||||
const nonCachedCustomer = await autumnV1.customers.get<ApiCustomerV3>(
|
||||
customerId,
|
||||
{ skip_cache: "true" },
|
||||
);
|
||||
expect(nonCachedCustomer.invoices?.[0].status).toBe("draft");
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Legacy Attach V1 Payment Failure Tests - 3DS Authentication Required
|
||||
*
|
||||
* Tests that V1 attach() returns checkout_url when payment method requires
|
||||
* 3DS authentication, and that completing confirmation resolves the flow.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. New subscription - auth PM from start
|
||||
* 2. Upgrade (pro → premium) - swap to auth PM
|
||||
* 3. Merged (add-on) - swap to auth PM
|
||||
* 4. Update quantity (prepaid increase) - swap to auth PM
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnIncrease } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import {
|
||||
expectProductAttached,
|
||||
expectProductNotAttached,
|
||||
} from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { completeInvoiceConfirmation } from "@tests/utils/stripeUtils/completeInvoiceConfirmation";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: New subscription - 3DS required from start
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has authenticate PM, attach pro
|
||||
* - Returns checkout_url
|
||||
* - Product NOT active until confirmation
|
||||
* - Complete confirmation → product active
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-3ds 1: new subscription")}`, async () => {
|
||||
const customerId = "legacy-3ds-new";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "authenticate" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Product should NOT be active yet
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined();
|
||||
|
||||
await completeInvoiceConfirmation({ url: res.checkout_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({ customer: customerAfter as any, product: pro });
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Upgrade (pro → premium) - 3DS required
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with success PM
|
||||
* - Swap to authenticate PM
|
||||
* - Upgrade to premium → checkout_url
|
||||
* - Still on pro until confirmation
|
||||
* - Complete confirmation → premium active
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-3ds 2: upgrade")}`, async () => {
|
||||
const customerId = "legacy-3ds-upgrade";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 500,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attachPaymentMethod({ type: "authenticate" }),
|
||||
],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Should still have pro's balance
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerBefore as any,
|
||||
product: pro,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerBefore,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await completeInvoiceConfirmation({ url: res.checkout_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: premium,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Merged (add-on) - 3DS required on merge
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with success PM
|
||||
* - Swap to authenticate PM
|
||||
* - Attach monthly add-on → checkout_url
|
||||
* - Add-on NOT attached until confirmation
|
||||
* - Complete confirmation → both products attached, merged sub
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-3ds 3: merged add-on")}`, async () => {
|
||||
const customerId = "legacy-3ds-merged";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
||||
const addOnPriceItem = items.monthlyPrice({ price: 10 });
|
||||
const addOn = products.base({
|
||||
id: "monthly-addon",
|
||||
isAddOn: true,
|
||||
items: [addOnMessagesItem, addOnPriceItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attachPaymentMethod({ type: "authenticate" }),
|
||||
],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Pro still attached, add-on NOT attached
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerBefore as any,
|
||||
product: pro,
|
||||
});
|
||||
expectProductNotAttached({
|
||||
customer: customerBefore as any,
|
||||
product: addOn,
|
||||
});
|
||||
|
||||
await completeInvoiceConfirmation({ url: res.checkout_url });
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: pro,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: customerAfter as any,
|
||||
product: addOn,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Update quantity (prepaid increase) - 3DS required
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with prepaid messages (quantity 300, V1 excludes allowance)
|
||||
* - Swap to authenticate PM
|
||||
* - Increase quantity to 500 → checkout_url
|
||||
* - Balance unchanged until confirmation
|
||||
* - Complete confirmation → balance updated
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-3ds 4: update quantity")}`, async () => {
|
||||
const customerId = "legacy-3ds-qty";
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: 100,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
},
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
// V1 quantity excludes allowance: 300 units = 3 packs
|
||||
const initialQuantityV1 = 300;
|
||||
const initialTotalBalance = 100 + initialQuantityV1; // 400
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantityV1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.attachPaymentMethod({ type: "authenticate" }),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
const customerInit = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerInit,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: initialTotalBalance,
|
||||
balance: initialTotalBalance,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Increase quantity to 500 (V1, excludes allowance)
|
||||
const updatedQuantityV1 = 500;
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: updatedQuantityV1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Balance should be unchanged
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerBefore,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: initialTotalBalance,
|
||||
balance: initialTotalBalance,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await completeInvoiceConfirmation({ url: res.checkout_url });
|
||||
|
||||
// After confirmation: new total = 100 (allowance) + 500 (prepaid) = 600
|
||||
const updatedTotalBalance = 100 + updatedQuantityV1; // 600
|
||||
|
||||
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerAfter,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: updatedTotalBalance,
|
||||
balance: updatedTotalBalance,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Legacy Attach V1 Payment Failure Tests - Payment Failed (Card Declined)
|
||||
*
|
||||
* Tests that V1 attach() returns checkout_url when payment method is declined.
|
||||
* These tests verify the failure state only — no recovery flow.
|
||||
*
|
||||
* Scenarios:
|
||||
* 1. New subscription - fail PM from start
|
||||
* 2. Upgrade (pro → premium) - swap to fail PM
|
||||
* 3. Merged (add-on) - swap to fail PM
|
||||
* 4. Update quantity (prepaid increase) - swap to fail PM
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { type ApiCustomerV3, OnIncrease } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import {
|
||||
expectProductAttached,
|
||||
expectProductNotAttached,
|
||||
} from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: New subscription - payment failed from start
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Customer has fail PM, attach pro
|
||||
* - Returns checkout_url
|
||||
* - Product NOT active (features undefined)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-fail 1: new subscription")}`, async () => {
|
||||
const customerId = "legacy-fail-new";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer({ paymentMethod: "fail" }), s.products({ list: [pro] })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expect(customer.features?.[TestFeature.Messages]).toBeUndefined();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Upgrade (pro → premium) - payment failed
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with success PM
|
||||
* - Swap to fail PM
|
||||
* - Upgrade to premium → checkout_url
|
||||
* - Still on pro with pro's balance
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-fail 2: upgrade")}`, async () => {
|
||||
const customerId = "legacy-fail-upgrade";
|
||||
|
||||
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [proMessagesItem],
|
||||
});
|
||||
|
||||
const premiumMessagesItem = items.monthlyMessages({
|
||||
includedUsage: 500,
|
||||
});
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [premiumMessagesItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attachPaymentMethod({ type: "fail" }),
|
||||
],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Should still have pro's balance (upgrade not applied)
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({ customer: customer as any, product: pro });
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Merged (add-on) - payment failed
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with success PM
|
||||
* - Swap to fail PM
|
||||
* - Attach monthly add-on → checkout_url
|
||||
* - Pro still attached, add-on NOT attached
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-fail 3: merged add-on")}`, async () => {
|
||||
const customerId = "legacy-fail-merged";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [messagesItem],
|
||||
});
|
||||
|
||||
const addOnMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
||||
const addOnPriceItem = items.monthlyPrice({ price: 10 });
|
||||
const addOn = products.base({
|
||||
id: "monthly-addon",
|
||||
isAddOn: true,
|
||||
items: [addOnMessagesItem, addOnPriceItem],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id }),
|
||||
s.attachPaymentMethod({ type: "fail" }),
|
||||
],
|
||||
});
|
||||
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Pro still attached, add-on NOT attached
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({ customer: customer as any, product: pro });
|
||||
expectProductNotAttached({ customer: customer as any, product: addOn });
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Update quantity (prepaid increase) - payment failed
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* - Attach pro with prepaid messages (quantity 300, V1 excludes allowance)
|
||||
* - Swap to fail PM
|
||||
* - Increase quantity to 500 → checkout_url
|
||||
* - Balance unchanged (still at original total)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("legacy-fail 4: update quantity")}`, async () => {
|
||||
const customerId = "legacy-fail-qty";
|
||||
|
||||
const prepaidItem = items.prepaidMessages({
|
||||
includedUsage: 100,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
},
|
||||
});
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
// V1 quantity excludes allowance: 300 units = 3 packs
|
||||
const initialQuantityV1 = 300;
|
||||
const initialTotalBalance = 100 + initialQuantityV1; // 400
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
quantity: initialQuantityV1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
s.attachPaymentMethod({ type: "fail" }),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
const customerInit = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerInit,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: initialTotalBalance,
|
||||
balance: initialTotalBalance,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Increase quantity to 500 (V1, excludes allowance)
|
||||
const res = await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
options: [{ feature_id: TestFeature.Messages, quantity: 500 }],
|
||||
});
|
||||
|
||||
expect(res.checkout_url).toBeDefined();
|
||||
|
||||
// Balance should be unchanged
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: initialTotalBalance,
|
||||
balance: initialTotalBalance,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Legacy Add-on Merged Subscription Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/addOn/mergedAddOn2.test.ts (add-ons across 2 entities)
|
||||
* - server/tests/merged/addOn/mergedAddOn6.test.ts (add-on quantity updates across 3 entities)
|
||||
*
|
||||
* Tests V1 attach (s.attach) behavior for:
|
||||
* - Attaching prepaid add-ons to entities alongside base products
|
||||
* - Updating add-on prepaid quantities per entity (increase/decrease)
|
||||
* - Multiple entities with different base products + shared add-on
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiEntityV0 } from "@autumn/shared";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Prepaid add-on across 2 entities with quantity update
|
||||
// (from mergedAddOn2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro product with Credits feature (free, no price on the feature)
|
||||
// - Prepaid add-on for Credits ($10 per 100 units)
|
||||
// - 2 entities
|
||||
// - Entity 1: Attach Pro, then add-on with 300 credits
|
||||
// - Entity 2: Attach Pro, then add-on with 500 credits
|
||||
// - Entity 2: Update add-on to 200 credits (decrease)
|
||||
//
|
||||
// Expected:
|
||||
// - Both entities have Pro (active) + add-on (active)
|
||||
// - Entity 1: 300 credits from add-on + includedUsage from Pro
|
||||
// - Entity 2: After decrease, 200 credits from add-on + includedUsage from Pro
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-addon-merged 1: prepaid add-on across 2 entities")}`, async () => {
|
||||
const customerId = "legacy-addon-merged-2ent";
|
||||
const billingUnits = 100;
|
||||
|
||||
const creditsItem = items.monthlyCredits({ includedUsage: 100 });
|
||||
const pro = products.pro({ id: "pro", items: [creditsItem] });
|
||||
|
||||
const prepaidCredits = items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
billingUnits,
|
||||
price: 10,
|
||||
});
|
||||
const addOn = products.base({
|
||||
id: "addon",
|
||||
items: [prepaidCredits],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
const entity1AddonQty = billingUnits * 3; // 300
|
||||
const entity2AddonQty = billingUnits * 5; // 500
|
||||
const entity2DecreasedQty = billingUnits * 2; // 200
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addOn] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Entity 1: Attach Pro
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
// Entity 2: Attach Pro
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
// Entity 1: Attach add-on with 300 credits
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity1AddonQty },
|
||||
],
|
||||
}),
|
||||
// Entity 2: Attach add-on with 500 credits
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity2AddonQty },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1: Pro + add-on active
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity1,
|
||||
active: [pro.id, addOn.id],
|
||||
});
|
||||
|
||||
// Verify entity 2: Pro + add-on active
|
||||
let entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity2,
|
||||
active: [pro.id, addOn.id],
|
||||
});
|
||||
|
||||
// Entity 2: Update add-on to 200 credits (decrease)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity2DecreasedQty },
|
||||
],
|
||||
});
|
||||
|
||||
// Re-verify entity 2 still has both products active
|
||||
entity2 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity2,
|
||||
active: [pro.id, addOn.id],
|
||||
});
|
||||
|
||||
// Verify subscription correctness
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Add-on quantity updates across 3 entities with mixed base products
|
||||
// (from mergedAddOn6)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium ($50) and Pro ($20) base products with Credits feature
|
||||
// - Prepaid add-on for Credits ($10 per 100 units)
|
||||
// - 3 entities
|
||||
// - Entity 1: Attach Premium, then add-on with 300 credits
|
||||
// - Entity 2: Attach Premium, then add-on with 500 credits
|
||||
// - Entity 3: Attach Pro, then add-on with 300 credits
|
||||
// - Entity 1: Update add-on to 500 credits (increase)
|
||||
//
|
||||
// Expected:
|
||||
// - Entity 1: Premium + add-on (500 credits after update)
|
||||
// - Entity 2: Premium + add-on (500 credits)
|
||||
// - Entity 3: Pro + add-on (300 credits)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-addon-merged 2: add-on updates across 3 entities with mixed products")}`, async () => {
|
||||
const customerId = "legacy-addon-merged-3ent";
|
||||
const billingUnits = 100;
|
||||
|
||||
const creditsItem = items.monthlyCredits({ includedUsage: 100 });
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [creditsItem],
|
||||
});
|
||||
const pro = products.pro({ id: "pro", items: [creditsItem] });
|
||||
|
||||
const prepaidCredits = items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
billingUnits,
|
||||
price: 10,
|
||||
});
|
||||
const addOn = products.base({
|
||||
id: "addon",
|
||||
items: [prepaidCredits],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
const ent1AddonQty = billingUnits * 3; // 300
|
||||
const ent2AddonQty = billingUnits * 5; // 500
|
||||
const ent3AddonQty = billingUnits * 3; // 300
|
||||
const ent1UpdatedQty = billingUnits * 5; // 500
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium, addOn] }),
|
||||
s.entities({ count: 3, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Entity 1: Premium + add-on (300)
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
entityIndex: 0,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: ent1AddonQty }],
|
||||
}),
|
||||
// Entity 2: Premium + add-on (500)
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
entityIndex: 1,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: ent2AddonQty }],
|
||||
}),
|
||||
// Entity 3: Pro + add-on (300)
|
||||
s.attach({ productId: pro.id, entityIndex: 2 }),
|
||||
s.attach({
|
||||
productId: addOn.id,
|
||||
entityIndex: 2,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: ent3AddonQty }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify all 3 entities have their base product + add-on
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const entity = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[i].id,
|
||||
);
|
||||
const baseProduct = i < 2 ? premium.id : pro.id;
|
||||
await expectCustomerProducts({
|
||||
customer: entity,
|
||||
active: [baseProduct, addOn.id],
|
||||
});
|
||||
}
|
||||
|
||||
// Entity 1: Update add-on to 500 credits (increase)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
entity_id: entities[0].id,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: ent1UpdatedQty }],
|
||||
});
|
||||
|
||||
// Verify entity 1 still has Premium + add-on active after update
|
||||
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
await expectCustomerProducts({
|
||||
customer: entity1,
|
||||
active: [premium.id, addOn.id],
|
||||
});
|
||||
|
||||
// Verify subscription correctness
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Legacy New + Merged Subscription Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/add/mergedAdd1.test.ts (merged subs with track + invoice)
|
||||
* - server/tests/merged/add/mergedAdd3.test.ts (scheduled downgrade with 3 entities)
|
||||
*
|
||||
* Tests V1 attach (s.attach) behavior for:
|
||||
* - Attaching same product to multiple entities (merged into single Stripe subscription)
|
||||
* - Tracking usage per entity and verifying end-of-cycle invoice totals
|
||||
* - Scheduled downgrades across entities
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3, CusProductStatus } from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Merged subs with consumable track + invoice verification
|
||||
// (from mergedAdd1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro product with consumable Words item ($0.05/word)
|
||||
// - 2 entities, attach Pro to both (merged into single Stripe sub)
|
||||
// - Track 110k words on entity 1, 310k words on entity 2
|
||||
// - Advance to next invoice, verify total = base*2 + usage charges
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-new-merged 1: merged subs with track and invoice")}`, async () => {
|
||||
const customerId = "legacy-new-merged-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
const value1 = 110000;
|
||||
const value2 = 310000;
|
||||
const values = [value1, value2];
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }),
|
||||
s.track({
|
||||
featureId: TestFeature.Words,
|
||||
value: value1,
|
||||
entityIndex: 0,
|
||||
timeout: 3000,
|
||||
}),
|
||||
s.track({
|
||||
featureId: TestFeature.Words,
|
||||
value: value2,
|
||||
entityIndex: 1,
|
||||
timeout: 3000,
|
||||
}),
|
||||
s.advanceToNextInvoice({ withPause: true }),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify sub is correct
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
// Calculate expected usage totals for each entity
|
||||
let usageTotal = 0;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const expectedTotal = await getExpectedInvoiceTotal({
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
usage: [{ featureId: TestFeature.Words, value: values[i] }],
|
||||
onlyIncludeUsage: true,
|
||||
stripeCli: ctx.stripeCli,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
usageTotal += expectedTotal;
|
||||
}
|
||||
|
||||
const basePrice = getBasePrice({ product: pro });
|
||||
|
||||
// Invoice total = base price * 2 entities + usage charges
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 3,
|
||||
latestTotal: basePrice * 2 + usageTotal,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Scheduled downgrade with 3 entities
|
||||
// (from mergedAdd3)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium ($50) and Pro ($20) products with Words feature
|
||||
// - 3 entities
|
||||
// - Attach Premium to entity 1, Premium to entity 2
|
||||
// - Downgrade entity 1 from Premium to Pro (scheduled)
|
||||
// - Attach Premium to entity 3
|
||||
//
|
||||
// Expected per-entity states:
|
||||
// - Entity 1: Premium (active, canceling) + Pro (scheduled)
|
||||
// - Entity 2: Premium (active)
|
||||
// - Entity 3: Premium (active)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-new-merged 2: scheduled downgrade with 3 entities")}`, async () => {
|
||||
const customerId = "legacy-new-merged-3";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [premium, pro] }),
|
||||
s.entities({ count: 3, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Attach Premium to entity 1
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
// Attach Premium to entity 2
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1 has Premium active
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Verify entity 2 has Premium active
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Downgrade entity 1 from Premium to Pro (should be scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Entity 1 should now have Premium (active) + Pro (scheduled)
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Attach Premium to entity 3
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[2].id,
|
||||
});
|
||||
|
||||
// Entity 3 should have Premium active
|
||||
const entity3 = await autumnV1.entities.get(customerId, entities[2].id);
|
||||
expectProductAttached({
|
||||
customer: entity3,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Verify subscription correctness
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Legacy Attach V1 Separate Subscription Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/separate/separate1.test.ts (separate subs via invoice checkout)
|
||||
* - server/tests/merged/separate/separate2.test.ts (separate subs via force_checkout + add-on)
|
||||
*
|
||||
* Tests V1 attach behavior when entities get separate subscriptions (not merged).
|
||||
* Separate subs are created when:
|
||||
* - invoice: true → creates invoice checkout per entity
|
||||
* - force_checkout: true → creates Stripe checkout session per entity
|
||||
*
|
||||
* Each entity gets its own Stripe subscription ID, not shared.
|
||||
*/
|
||||
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { LegacyVersion } from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { completeCheckoutForm } from "@tests/utils/stripeUtils";
|
||||
import { completeInvoiceCheckout } from "@tests/utils/stripeUtils/completeInvoiceCheckout";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Separate subscriptions via invoice checkout
|
||||
// (from separate1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro ($20) and Premium ($50) products with 100 messages
|
||||
// - 2 entities, no payment method on customer
|
||||
// - Attach Pro to entity 1 with invoice: true → get checkout_url, complete it
|
||||
// - Attach Pro to entity 2 with invoice: true → get checkout_url, complete it
|
||||
// - Verify entity 1 and entity 2 have DIFFERENT subscription IDs
|
||||
// - Upgrade both entities to Premium (normal attach, no invoice)
|
||||
// - Verify subs remain separate and correct
|
||||
//
|
||||
// Expected:
|
||||
// - Each entity gets its own subscription
|
||||
// - After upgrade, subs are still separate
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-separate 1: separate subs via invoice checkout")}`, async () => {
|
||||
const customerId = "legacy-separate-1";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
// Use v1.2 client (matches original test)
|
||||
const autumnV1_2 = new AutumnInt({ version: LegacyVersion.v1_2 });
|
||||
|
||||
const { entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
// No payment method — invoice checkout will provide the payment page
|
||||
s.customer({ testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Note: initScenario mutates product.id in-place, so pro.id/premium.id are already prefixed
|
||||
|
||||
// Attach Pro to entity 1 with invoice: true
|
||||
const res1 = await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
invoice: true,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
await completeInvoiceCheckout({ url: res1.checkout_url });
|
||||
|
||||
// Attach Pro to entity 2 with invoice: true
|
||||
const res2 = await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
invoice: true,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
await completeInvoiceCheckout({ url: res2.checkout_url });
|
||||
|
||||
// Verify different subscription IDs per entity
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const cusProducts = fullCus.customer_products;
|
||||
const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id);
|
||||
const entity2Prod = cusProducts.find((cp) => cp.entity_id === entities[1].id);
|
||||
|
||||
const entity1SubId = entity1Prod?.subscription_ids?.[0];
|
||||
const entity2SubId = entity2Prod?.subscription_ids?.[0];
|
||||
|
||||
expect(entity1SubId).toBeDefined();
|
||||
expect(entity2SubId).toBeDefined();
|
||||
expect(entity1SubId).not.toBe(entity2SubId);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity1SubId,
|
||||
});
|
||||
|
||||
// Upgrade both entities to Premium (normal attach, not invoice mode)
|
||||
await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity1SubId,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity2SubId,
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Separate subscriptions via force_checkout + add-on
|
||||
// (from separate2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro ($20) and Premium ($50) products with 100 messages
|
||||
// - Credits add-on (prepaid, $10/100 credits)
|
||||
// - 2 entities, no payment method on customer
|
||||
// - Attach Pro to entity 1 with force_checkout → complete checkout form
|
||||
// - Attach Pro to entity 2 with force_checkout → complete checkout form
|
||||
// - Verify entity 1 and entity 2 have DIFFERENT subscription IDs
|
||||
// - Upgrade both entities to Premium (normal attach)
|
||||
// - Attach add-on to entity 2 → should merge into entity 2's sub
|
||||
// - Verify add-on's sub ID matches entity 2's sub ID
|
||||
//
|
||||
// Expected:
|
||||
// - Each entity gets its own subscription
|
||||
// - Add-on merges into the correct entity's subscription
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-separate 2: separate subs via force_checkout + add-on")}`, async () => {
|
||||
const customerId = "legacy-separate-2";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
const addOnBillingUnits = 100;
|
||||
const addOn = products.base({
|
||||
id: "credits-addon",
|
||||
items: [
|
||||
items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
billingUnits: addOnBillingUnits,
|
||||
includedUsage: 0,
|
||||
price: 10,
|
||||
}),
|
||||
],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
// Use v1.2 client (matches original test)
|
||||
const autumnV1_2 = new AutumnInt({ version: LegacyVersion.v1_2 });
|
||||
|
||||
const { entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
// No payment method — force_checkout will provide the payment page
|
||||
s.customer({ testClock: true }),
|
||||
s.products({ list: [pro, premium, addOn] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Note: initScenario mutates product.id in-place, so pro.id/premium.id/addOn.id are already prefixed
|
||||
|
||||
// Attach Pro to entity 1 with force_checkout
|
||||
const res1 = await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
force_checkout: true,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
expect(res1.checkout_url).toBeDefined();
|
||||
await completeCheckoutForm(res1.checkout_url);
|
||||
|
||||
// Attach Pro to entity 2 with force_checkout
|
||||
const res2 = await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
force_checkout: true,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
expect(res2.checkout_url).toBeDefined();
|
||||
await completeCheckoutForm(res2.checkout_url);
|
||||
|
||||
// Verify different subscription IDs per entity
|
||||
let fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
let cusProducts = fullCus.customer_products;
|
||||
const entity1Prod = cusProducts.find((cp) => cp.entity_id === entities[0].id);
|
||||
const entity2Prod = cusProducts.find((cp) => cp.entity_id === entities[1].id);
|
||||
|
||||
const entity1SubId = entity1Prod?.subscription_ids?.[0];
|
||||
const entity2SubId = entity2Prod?.subscription_ids?.[0];
|
||||
|
||||
expect(entity1SubId).toBeDefined();
|
||||
expect(entity2SubId).toBeDefined();
|
||||
expect(entity1SubId).not.toBe(entity2SubId);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity1SubId,
|
||||
});
|
||||
|
||||
// Upgrade both entities to Premium
|
||||
for (const entity of entities) {
|
||||
await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entity.id,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity1SubId!,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity2SubId!,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach add-on to entity 2 (should merge into entity 2's sub)
|
||||
await autumnV1_2.attach({
|
||||
customer_id: customerId,
|
||||
product_id: addOn.id,
|
||||
entity_id: entities[1].id,
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: addOnBillingUnits * 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify add-on's sub ID matches entity 2's sub ID
|
||||
fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
cusProducts = fullCus.customer_products;
|
||||
const addOnProd = cusProducts.find((cp) => cp.product.id === addOn.id);
|
||||
|
||||
expect(addOnProd).toBeDefined();
|
||||
const addOnSubId = addOnProd?.subscription_ids?.[0];
|
||||
expect(addOnSubId).toBe(entity2SubId);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
subId: entity2SubId,
|
||||
});
|
||||
}, 120000);
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Legacy Attach V1 Trial - Merged Entity Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/trial/mergedTrial1.test.ts (trial anchor alignment for entities)
|
||||
* - server/tests/merged/trial/mergedTrial2.test.ts (add second entity after trial ends)
|
||||
* - server/tests/merged/trial/mergedTrial3.test.ts (upgrade to premium in merged trial state)
|
||||
*
|
||||
* Tests V1 attach behavior for trial products with entities in merged subscriptions.
|
||||
*/
|
||||
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
expectProductNotTrialing,
|
||||
expectProductTrialing,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { addDays } from "date-fns";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Trial anchor alignment for entities
|
||||
// (from mergedTrial1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium product with trial (7 days) + consumable Words
|
||||
// - 2 entities
|
||||
// - Attach Premium trial to entity 1
|
||||
// - Advance clock 2 days (still in trial)
|
||||
// - Preview checkout for entity 2 → next_cycle.starts_at should match entity 1's period_end
|
||||
// - Attach Premium to entity 2 → should be trialing, aligned to entity 1's cycle
|
||||
//
|
||||
// Expected:
|
||||
// - Entity 2's trial aligns with entity 1's billing cycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-trial-merged 1: trial anchor alignment for entities")}`, async () => {
|
||||
const customerId = "legacy-trial-merged-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
|
||||
const { autumnV1, testClockId, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.attach({ productId: premium.id, entityIndex: 0 })],
|
||||
});
|
||||
|
||||
// Advance clock 2 days (still within trial)
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
advanceTo: addDays(new Date(), 2).getTime(),
|
||||
});
|
||||
|
||||
// Check entity 1's current period end
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
const premium1 = entity1.products.find((p: any) => p.id === premium.id);
|
||||
expect(premium1?.current_period_end).toBeDefined();
|
||||
const periodEnd = premium1!.current_period_end as number;
|
||||
|
||||
// Preview checkout for entity 2 — next_cycle should align with entity 1
|
||||
const checkout = await autumnV1.checkout({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
const nextCycle = checkout.next_cycle;
|
||||
expect(nextCycle?.starts_at).toBeDefined();
|
||||
expect(Math.abs((nextCycle?.starts_at ?? 0) - periodEnd)).toBeLessThanOrEqual(
|
||||
60000,
|
||||
); // 1 min tolerance
|
||||
|
||||
// Attach Premium to entity 2
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
// Entity 2 should be trialing, with period_end aligned to entity 1
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
await expectProductTrialing({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
trialEndsAt: periodEnd,
|
||||
toleranceMs: 60000,
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Add second entity after trial ends
|
||||
// (from mergedTrial2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium product with trial (7 days) + consumable Words
|
||||
// - 2 entities
|
||||
// - Attach Premium trial to entity 1
|
||||
// - Advance clock 8 days (past trial end → Premium becomes active)
|
||||
// - Attach Premium to entity 2 → should still work (attach + expect correct)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-trial-merged 2: add second entity after trial ends")}`, async () => {
|
||||
const customerId = "legacy-trial-merged-2";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
|
||||
const { autumnV1, testClockId, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [s.attach({ productId: premium.id, entityIndex: 0 })],
|
||||
});
|
||||
|
||||
// Advance clock 8 days (past the 7-day trial)
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
advanceTo: addDays(new Date(), 8).getTime(),
|
||||
});
|
||||
|
||||
// Attach Premium to entity 2 (after entity 1's trial has ended)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
// Entity 2 should have Premium attached
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: premium.id,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Upgrade entities from pro trial to premium (not trialing after upgrade)
|
||||
// (from mergedTrial3)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro product with trial (7 days) + consumable Words
|
||||
// - Premium product with trial (7 days) + consumable Words
|
||||
// - 2 entities
|
||||
// - Attach Pro trial to entity 1 and entity 2
|
||||
// - Advance clock 8 days (past trial)
|
||||
// - Upgrade entity 1 to Premium → should NOT be trialing (upgrade from active)
|
||||
// - Upgrade entity 2 to Premium → should NOT be trialing
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-trial-merged 3: upgrade entities from trial pro to premium (not trialing)")}`, async () => {
|
||||
const customerId = "legacy-trial-merged-3";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [wordsItem, proPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
|
||||
const { autumnV1, testClockId, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Advance clock 8 days (past the 7-day trial → Pro becomes active)
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
advanceTo: addDays(new Date(), 8).getTime(),
|
||||
});
|
||||
|
||||
// Upgrade entity 1 to Premium → should NOT be trialing (upgrade from active)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductNotTrialing({
|
||||
customer: entity1,
|
||||
productId: premium.id,
|
||||
});
|
||||
|
||||
// Upgrade entity 2 to Premium → should NOT be trialing
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
});
|
||||
await expectProductNotTrialing({
|
||||
customer: entity2,
|
||||
productId: premium.id,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
}, 120000);
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Legacy Attach V1 Trial - MainIsTrial Branch Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/trial/trial1.test.ts (upgrade during trial: pro trial → premium trial)
|
||||
* - server/tests/merged/trial/trial2.test.ts (upgrade after trial ends: pro trial → active → premium trial)
|
||||
*
|
||||
* Tests V1 attach behavior for the MainIsTrial branch where a customer upgrades
|
||||
* from one trial product to another.
|
||||
*/
|
||||
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
AttachBranch,
|
||||
CusProductStatus,
|
||||
} from "@autumn/shared";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
import { addDays } from "date-fns";
|
||||
import { Decimal } from "decimal.js";
|
||||
import { timeout } from "@/utils/genUtils";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Upgrade during trial (pro trial → premium trial)
|
||||
// (from trial1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro with 7-day trial + consumable Words
|
||||
// - Premium with 7-day trial + consumable Words
|
||||
// - Attach Pro → customer is trialing
|
||||
// - Advance clock 2 days (still in trial)
|
||||
// - Preview attach → branch should be MainIsTrial
|
||||
// - Attach Premium → customer still trialing with Premium
|
||||
// - Premium period_end ≈ curUnix + 7 days (new trial starts)
|
||||
//
|
||||
// Expected:
|
||||
// - Premium trialing after upgrade
|
||||
// - period_end ≈ current time + 7 days
|
||||
// - Sub is correct in DB
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-trial 1: upgrade during trial (pro → premium)")}`, async () => {
|
||||
const customerId = "legacy-trial-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [wordsItem, proPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
|
||||
const { autumnV1, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify Pro is trialing
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Trialing,
|
||||
});
|
||||
|
||||
// Advance clock 2 days (still in trial)
|
||||
const curUnix = await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
advanceTo: addDays(new Date(), 2).getTime(),
|
||||
});
|
||||
|
||||
// Preview attach → should be MainIsTrial branch
|
||||
const attachPreview = await autumnV1.attachPreview({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
expect(attachPreview?.branch).toBe(AttachBranch.MainIsTrial);
|
||||
|
||||
// Upgrade to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Trialing,
|
||||
});
|
||||
|
||||
// Premium period_end ≈ curUnix + 7 days
|
||||
const product = customer.products.find((p: any) => p.id === premium.id)!;
|
||||
expect(product.current_period_end).toBeDefined();
|
||||
expect(
|
||||
Math.abs(product.current_period_end! - addDays(curUnix, 7).getTime()),
|
||||
).toBeLessThanOrEqual(1000 * 60 * 30); // 30 min tolerance
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Upgrade after trial ends (pro trial → active → premium trial)
|
||||
// (from trial2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro with 7-day trial + consumable Words
|
||||
// - Premium with 7-day trial + consumable Words
|
||||
// - Attach Pro → customer is trialing
|
||||
// - Advance clock 8 days (past trial → Pro becomes active)
|
||||
// - Preview attach → branch should be Upgrade (not MainIsTrial)
|
||||
// - Checkout → get expected total
|
||||
// - Attach Premium → customer now trialing with Premium
|
||||
// - Invoice total matches checkout preview
|
||||
// - Premium period_end ≈ curUnix + 7 days
|
||||
//
|
||||
// Expected:
|
||||
// - Branch is Upgrade (not MainIsTrial, since trial ended)
|
||||
// - Premium trialing after upgrade
|
||||
// - Invoice total matches checkout
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-trial 2: upgrade after trial ends (pro active → premium trial)")}`, async () => {
|
||||
const customerId = "legacy-trial-2";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [wordsItem, proPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [wordsItem, premiumPrice],
|
||||
trialDays: 7,
|
||||
});
|
||||
|
||||
const { autumnV1, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify Pro is trialing
|
||||
let customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Trialing,
|
||||
});
|
||||
|
||||
// Advance clock 8 days (past the 7-day trial → Pro becomes active)
|
||||
const curUnix = await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
advanceTo: addDays(new Date(), 8).getTime(),
|
||||
});
|
||||
|
||||
// Preview attach → should be Upgrade branch (trial ended, product is active)
|
||||
const attachPreview = await autumnV1.attachPreview({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
expect(attachPreview?.branch).toBe(AttachBranch.Upgrade);
|
||||
|
||||
// Get checkout total for comparison
|
||||
const checkoutRes = await autumnV1.checkout({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
// Upgrade to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
});
|
||||
|
||||
await timeout(5000);
|
||||
|
||||
customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
expectProductAttached({
|
||||
customer: customer as any,
|
||||
productId: premium.id,
|
||||
status: CusProductStatus.Trialing,
|
||||
});
|
||||
|
||||
// Premium period_end ≈ curUnix + 7 days
|
||||
const product = customer.products.find((p: any) => p.id === premium.id)!;
|
||||
expect(product.current_period_end).toBeDefined();
|
||||
expect(
|
||||
Math.abs(product.current_period_end! - addDays(curUnix, 7).getTime()),
|
||||
).toBeLessThanOrEqual(1000 * 60 * 30); // 30 min tolerance
|
||||
|
||||
// Invoice total should match checkout preview
|
||||
expect(customer.invoices[0].total).toBe(
|
||||
new Decimal(checkoutRes.total).toDP(2).toNumber(),
|
||||
);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
shouldBeTrialing: true,
|
||||
});
|
||||
}, 120000);
|
||||
@@ -1,29 +1,35 @@
|
||||
/**
|
||||
* Legacy Update Quantity with Entities Tests
|
||||
*
|
||||
* Migrated from: server/tests/merged/prepaid/mergedPrepaid1.test.ts
|
||||
* Migrated from:
|
||||
* - server/tests/merged/prepaid/mergedPrepaid1.test.ts (Test 1)
|
||||
* - server/tests/merged/prepaid/mergedPrepaid2.test.ts (Test 2)
|
||||
* - server/tests/merged/prepaid/mergedPrepaid3.test.ts (Test 3)
|
||||
*
|
||||
* Tests for prepaid quantity updates with entity-level subscriptions.
|
||||
*
|
||||
* Scenario:
|
||||
* - 2 entities with prepaid credits (includedUsage: 100, billingUnits: 100)
|
||||
* - Entity 1: Attach pro with 400 credits → update to 500 credits (increase)
|
||||
* - Entity 2: Attach pro with 300 credits → update to 100 credits (decrease)
|
||||
*
|
||||
* Key behaviors tested:
|
||||
* - Entity-level prepaid product attachments
|
||||
* - Quantity increase (immediate update with prorate)
|
||||
* - Quantity decrease (upcoming_quantity set, no immediate change)
|
||||
* - Quantity decrease (upcoming_quantity set, no immediate change / prorate immediately)
|
||||
* - Subscription correctness for each entity
|
||||
* - Test clock advancement to verify next-cycle behavior
|
||||
* - Prepaid downgrade across entities (Premium → Pro scheduled)
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { type ApiEntityV0, OnDecrease, OnIncrease } from "@autumn/shared";
|
||||
import {
|
||||
type ApiEntityV0,
|
||||
CusProductStatus,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectProductItemCorrect } from "@tests/integration/billing/utils/expectProductItemCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
@@ -77,7 +83,6 @@ test.concurrent(`${chalk.yellowBright("legacy-entities: prepaid quantity increas
|
||||
const entity2InitialV1 = billingUnits * 3; // 300
|
||||
const entity2InitialTotal = includedUsage + entity2InitialV1; // 400
|
||||
const entity2DowngradedV1 = billingUnits * 1; // 100
|
||||
const entity2DowngradedTotal = includedUsage + entity2DowngradedV1; // 200
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
@@ -160,8 +165,8 @@ test.concurrent(`${chalk.yellowBright("legacy-entities: prepaid quantity increas
|
||||
customer: entity2After,
|
||||
productId: pro.id,
|
||||
featureId: TestFeature.Credits,
|
||||
quantity: entity2InitialTotal, // 400 (current)
|
||||
upcomingQuantity: entity2DowngradedTotal, // 200 (next cycle)
|
||||
quantity: entity2InitialV1, // 400 (current)
|
||||
upcomingQuantity: entity2DowngradedV1, // 200 (next cycle)
|
||||
});
|
||||
|
||||
// Verify subscriptions are correct for both entities
|
||||
@@ -181,3 +186,253 @@ test.concurrent(`${chalk.yellowBright("legacy-entities: prepaid quantity increas
|
||||
entityId: "ent-2",
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Prepaid quantity decrease with entities + test clock advancement
|
||||
// (Migrated from mergedPrepaid2.test.ts)
|
||||
//
|
||||
// Scenario:
|
||||
// - 2 entities with prepaid Credits (OnDecrease.None)
|
||||
// - Entity 1: Attach Pro with 400 credits → re-attach Pro with 200 credits (decrease)
|
||||
// - Entity 2: Attach Pro with 300 credits → re-attach Pro with 100 credits (decrease)
|
||||
// - Advance test clock to next invoice to verify next-cycle state
|
||||
//
|
||||
// Expected:
|
||||
// - Both entities keep current balance until cycle ends (OnDecrease.None)
|
||||
// - After cycle advancement, subscription renews with decreased quantities
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-entities 2: prepaid decrease + test clock advancement")}`, async () => {
|
||||
const customerId = "legacy-ent-qty-decrease-cycle";
|
||||
const billingUnits = 100;
|
||||
const pricePerPack = 10;
|
||||
const includedUsage = 100;
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
billingUnits,
|
||||
price: pricePerPack,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.None,
|
||||
},
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
// Entity 1: Initial 400, decrease to 200
|
||||
const entity1InitialV1 = billingUnits * 4; // 400
|
||||
const entity1DecreasedV1 = billingUnits * 2; // 200
|
||||
|
||||
// Entity 2: Initial 300, decrease to 100
|
||||
const entity2InitialV1 = billingUnits * 3; // 300
|
||||
const entity2DecreasedV1 = billingUnits * 1; // 100
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Entity 1: Attach pro with 400 credits
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity1InitialV1 },
|
||||
],
|
||||
}),
|
||||
// Entity 2: Attach pro with 300 credits
|
||||
s.attach({
|
||||
productId: pro.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity2InitialV1 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Decrease to 200 credits
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: "ent-1",
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity1DecreasedV1 },
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 2: Decrease to 100 credits
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: "ent-2",
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity2DecreasedV1 },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify balances haven't changed yet (OnDecrease.None)
|
||||
const entity1Before = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
"ent-1",
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity1Before,
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: includedUsage + entity1InitialV1, // Still 500
|
||||
balance: includedUsage + entity1InitialV1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
const entity2Before = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
"ent-2",
|
||||
);
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: entity2Before,
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: includedUsage + entity2InitialV1, // Still 400
|
||||
balance: includedUsage + entity2InitialV1,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Verify subscription correctness
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Prepaid downgrade across entities (Premium → Pro scheduled)
|
||||
// (Migrated from mergedPrepaid3.test.ts)
|
||||
//
|
||||
// Scenario:
|
||||
// - 2 entities with prepaid Credits (OnDecrease.ProrateImmediately)
|
||||
// - Entity 1: Attach Premium with 400 credits
|
||||
// - Entity 2: Attach Premium with 300 credits
|
||||
// - Entity 1: Downgrade to Pro with 200 credits (scheduled)
|
||||
// - Advance test clock to next invoice
|
||||
// - Verify entity 1 now has Pro after cycle ends
|
||||
//
|
||||
// Expected:
|
||||
// - After downgrade, entity 1 has Premium (canceling) + Pro (scheduled)
|
||||
// - After cycle ends, entity 1 has Pro active
|
||||
// - Entity 2 still has Premium active throughout
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-entities 3: prepaid downgrade Premium → Pro with entities")}`, async () => {
|
||||
const customerId = "legacy-ent-prepaid-downgrade";
|
||||
const billingUnits = 100;
|
||||
const pricePerPack = 10;
|
||||
const includedUsage = 100;
|
||||
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
billingUnits,
|
||||
price: pricePerPack,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.ProrateImmediately,
|
||||
},
|
||||
});
|
||||
|
||||
const premium = products.premium({
|
||||
id: "premium",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const pro = products.pro({
|
||||
id: "pro",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
// Entity 1: Attach Premium with 400, then downgrade to Pro with 200
|
||||
const entity1InitialV1 = billingUnits * 4; // 400
|
||||
const entity1DowngradeV1 = billingUnits * 2; // 200
|
||||
|
||||
// Entity 2: Attach Premium with 300
|
||||
const entity2InitialV1 = billingUnits * 3; // 300
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
// Entity 1: Attach Premium with 400 credits
|
||||
s.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 0,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity1InitialV1 },
|
||||
],
|
||||
}),
|
||||
// Entity 2: Attach Premium with 300 credits
|
||||
s.attach({
|
||||
productId: premium.id,
|
||||
entityIndex: 1,
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity2InitialV1 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Entity 1: Downgrade to Pro with 200 credits (scheduled since Pro < Premium)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: "ent-1",
|
||||
options: [
|
||||
{ feature_id: TestFeature.Credits, quantity: entity1DowngradeV1 },
|
||||
],
|
||||
});
|
||||
|
||||
// Verify entity 1 has Premium (canceling) + Pro (scheduled)
|
||||
const entity1After = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
"ent-1",
|
||||
);
|
||||
expectProductAttached({
|
||||
customer: entity1After,
|
||||
productId: premium.id,
|
||||
isCanceled: true,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1After,
|
||||
productId: pro.id,
|
||||
status: CusProductStatus.Scheduled,
|
||||
});
|
||||
|
||||
// Verify entity 2 still has Premium active
|
||||
const entity2After = await autumnV1.entities.get<ApiEntityV0>(
|
||||
customerId,
|
||||
"ent-2",
|
||||
);
|
||||
await expectProductActive({
|
||||
customer: entity2After,
|
||||
productId: premium.id,
|
||||
});
|
||||
|
||||
// Verify subscription correctness before cycle advancement
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Legacy Attach V1 Upgrade - Custom Items Tests
|
||||
*
|
||||
* Tests that verify V1's attach() with is_custom + items parameter works
|
||||
* correctly for upgrade scenarios. Custom items allow overriding product
|
||||
* configuration at attach time (price changes, feature additions, usage changes,
|
||||
* billing interval changes).
|
||||
*
|
||||
* Uses autumnV1.attach({ is_custom: true, items: [...] }) which goes through
|
||||
* the legacy /attach endpoint with custom product item overrides.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import type { ApiCustomerV3 } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import {
|
||||
expectCustomerProducts,
|
||||
expectProductActive,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Upgrade with custom higher price (downgrade→upgrade inversion)
|
||||
//
|
||||
// Scenario:
|
||||
// - Customer on Pro ($20/mo)
|
||||
// - Pro is cheaper than Premium normally, so Pro→Premium = upgrade
|
||||
// - But here we upgrade to Premium with custom price $60/mo (higher than Pro's $20)
|
||||
// - This tests that custom items correctly influence the upgrade path
|
||||
//
|
||||
// Expected:
|
||||
// - Treated as upgrade (immediate switch)
|
||||
// - Premium active, Pro gone
|
||||
// - Invoice reflects prorated difference
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-custom 1: upgrade with custom higher price")}`, async () => {
|
||||
const customerId = "legacy-upgrade-custom-price";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify customer is on Pro
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({
|
||||
customer: customerBefore,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Upgrade to Premium with custom higher price ($60/mo instead of $50)
|
||||
const customHigherPrice = items.monthlyPrice({ price: 60 });
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
is_custom: true,
|
||||
items: [messagesItem, customHigherPrice],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Premium should be active, Pro should be gone (immediate switch)
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
// Invoice: initial Pro ($20) + upgrade difference ($60 - $20 = $40)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 60 - 20,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Upgrade with custom feature addition
|
||||
//
|
||||
// Scenario:
|
||||
// - Customer on Pro (messages only, $20/mo)
|
||||
// - Upgrade to Premium ($50/mo) with custom items that add Words feature
|
||||
//
|
||||
// Expected:
|
||||
// - Premium active with both Messages and Words features
|
||||
// - Pro gone (immediate switch, since $50 > $20)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-custom 2: upgrade with custom feature addition")}`, async () => {
|
||||
const customerId = "legacy-upgrade-custom-feature";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify customer is on Pro
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({
|
||||
customer: customerBefore,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Upgrade to Premium with custom items adding Words feature
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 200 });
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
is_custom: true,
|
||||
items: [messagesItem, wordsItem, premiumPrice],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Premium should be active, Pro should be gone
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
// Messages from Premium
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Words from custom items
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Words,
|
||||
includedUsage: 200,
|
||||
balance: 200,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Invoice: initial Pro ($20) + upgrade to Premium ($50 - $20 = $30)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 50 - 20,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Upgrade with custom included usage
|
||||
//
|
||||
// Scenario:
|
||||
// - Customer on Pro (100 messages, $20/mo)
|
||||
// - Upgrade to Premium ($50/mo) with custom items setting 500 messages
|
||||
//
|
||||
// Expected:
|
||||
// - Premium active with 500 messages included
|
||||
// - Balance = 500
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-custom 3: upgrade with custom included usage")}`, async () => {
|
||||
const customerId = "legacy-upgrade-custom-usage";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify customer is on Pro with 100 messages
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({
|
||||
customer: customerBefore,
|
||||
productId: pro.id,
|
||||
});
|
||||
expectCustomerFeatureCorrect({
|
||||
customer: customerBefore,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
});
|
||||
|
||||
// Upgrade to Premium with custom 500 messages included
|
||||
const higherUsageItem = items.monthlyMessages({ includedUsage: 500 });
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
is_custom: true,
|
||||
items: [higherUsageItem, premiumPrice],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Premium should be active with 500 messages
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
balance: 500,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Invoice: initial Pro ($20) + upgrade ($50 - $20 = $30)
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
latestTotal: 50 - 20,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Upgrade with custom billing interval change
|
||||
//
|
||||
// Scenario:
|
||||
// - Customer on Pro ($20/mo monthly)
|
||||
// - Upgrade to Premium with custom items using annual price ($200/yr)
|
||||
//
|
||||
// Expected:
|
||||
// - Premium active with annual billing
|
||||
// - Charged $200 (annual) minus prorated $20 (monthly) credit
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-custom 4: upgrade with custom billing interval")}`, async () => {
|
||||
const customerId = "legacy-upgrade-custom-interval";
|
||||
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const proPrice = items.monthlyPrice({ price: 20 });
|
||||
const premiumPrice = items.monthlyPrice({ price: 50 });
|
||||
|
||||
const pro = products.base({
|
||||
id: "pro",
|
||||
items: [messagesItem, proPrice],
|
||||
});
|
||||
const premium = products.base({
|
||||
id: "premium",
|
||||
items: [messagesItem, premiumPrice],
|
||||
});
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
],
|
||||
actions: [s.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
// Verify customer is on Pro
|
||||
const customerBefore =
|
||||
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
await expectProductActive({
|
||||
customer: customerBefore,
|
||||
productId: pro.id,
|
||||
});
|
||||
|
||||
// Upgrade to Premium with custom annual price ($200/yr)
|
||||
const annualPrice = items.annualPrice({ price: 200 });
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
is_custom: true,
|
||||
items: [messagesItem, annualPrice],
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
// Premium should be active, Pro should be gone
|
||||
await expectCustomerProducts({
|
||||
customer,
|
||||
active: [premium.id],
|
||||
notPresent: [pro.id],
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Invoice: initial Pro ($20) + upgrade to annual Premium
|
||||
// The upgrade invoice amount = $200 (annual) - prorated remaining Pro credit
|
||||
// Exact amount depends on proration, so just verify 2 invoices exist
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db: ctx.db,
|
||||
customerId,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Legacy Attach V1 Upgrade - Merged Entity Tests
|
||||
*
|
||||
* Migrated from:
|
||||
* - server/tests/merged/upgrade/mergedUpgrade1.test.ts (upgrade entity in merged sub + invoice)
|
||||
* - server/tests/merged/upgrade/mergedUpgrade2.test.ts (upgrade cancels scheduled downgrade)
|
||||
* - server/tests/merged/upgrade/mergedUpgrade3.test.ts (upgrade cancels scheduled downgrade, both entities)
|
||||
* - server/tests/merged/upgrade/mergedUpgrade4.test.ts (upgrade cancels scheduled cancel/free)
|
||||
*
|
||||
* Tests V1 attach (s.attach) behavior for upgrade scenarios in merged entity subscriptions.
|
||||
*/
|
||||
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: test file */
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, CusProductStatus } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils";
|
||||
import chalk from "chalk";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Upgrade entity in merged sub + usage invoice verification
|
||||
// (from mergedUpgrade1)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro and Premium products with consumable Words
|
||||
// - 2 entities, attach Pro to both → merged sub
|
||||
// - Track 100k words on entity 1, 300k on entity 2
|
||||
// - Advance clock 2 weeks, upgrade entity 1 from Pro to Premium
|
||||
// - Advance to next invoice
|
||||
// - Verify invoice total = Pro base + Premium base + entity 2 usage
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 1: upgrade entity in merged sub + invoice")}`, async () => {
|
||||
const customerId = "legacy-upgrade-merged-1";
|
||||
|
||||
const wordsItem = items.consumableWords();
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
|
||||
const entity1Val = 100000;
|
||||
const entity2Val = 300000;
|
||||
|
||||
const { autumnV1, testClockId } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success", testClock: true }),
|
||||
s.products({ list: [pro, premium] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1, timeout: 3000 }),
|
||||
s.track({
|
||||
featureId: TestFeature.Words,
|
||||
value: entity1Val,
|
||||
entityIndex: 0,
|
||||
timeout: 3000,
|
||||
}),
|
||||
s.track({
|
||||
featureId: TestFeature.Words,
|
||||
value: entity2Val,
|
||||
entityIndex: 1,
|
||||
timeout: 3000,
|
||||
}),
|
||||
s.advanceTestClock({ weeks: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Upgrade entity 1 from Pro to Premium
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: "ent-1",
|
||||
});
|
||||
|
||||
// Advance to next invoice to check usage billing
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
// Entity 2's usage on pro should show up on the invoice
|
||||
const expectedUsageTotal = await getExpectedInvoiceTotal({
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
usage: [{ featureId: TestFeature.Words, value: entity2Val }],
|
||||
onlyIncludeUsage: true,
|
||||
stripeCli: ctx.stripeCli,
|
||||
db: ctx.db,
|
||||
org: ctx.org,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const basePrice =
|
||||
getBasePrice({ product: pro }) + getBasePrice({ product: premium });
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoice = customer.invoices![0];
|
||||
expect(invoice.total).toBe(basePrice + expectedUsageTotal);
|
||||
}, 120000);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 2: Upgrade cancels scheduled downgrade (single entity)
|
||||
// (from mergedUpgrade2)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium, Pro, Free, Growth products with Words feature
|
||||
// - 2 entities, attach Premium to both
|
||||
// - Downgrade entity 1 from Premium to Pro (scheduled)
|
||||
// - Upgrade entity 1 to Growth → cancels scheduled Pro, immediate switch
|
||||
//
|
||||
// Expected:
|
||||
// - Entity 1: Growth (active)
|
||||
// - Entity 2: Premium (active)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 2: upgrade cancels scheduled downgrade")}`, async () => {
|
||||
const customerId = "legacy-upgrade-merged-2";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [wordsItem],
|
||||
});
|
||||
const growth = products.growth({ id: "growth", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, free, premium, growth] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Downgrade entity 1 from Premium to Pro (should be scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Verify entity 1 has Premium (active) + Pro (scheduled)
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Upgrade entity 1 to Growth → should cancel scheduled Pro and immediate switch
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: growth.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: growth.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Entity 2 should still have Premium
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 3: Upgrade cancels scheduled downgrade (both entities downgraded)
|
||||
// (from mergedUpgrade3)
|
||||
//
|
||||
// Scenario:
|
||||
// - Premium, Pro, Free, Growth products with Words feature
|
||||
// - 2 entities, attach Premium to both
|
||||
// - Downgrade entity 1 from Premium to Pro (scheduled)
|
||||
// - Downgrade entity 2 from Premium to Pro (scheduled)
|
||||
// - Upgrade entity 2 to Growth → cancels scheduled Pro, immediate switch
|
||||
//
|
||||
// Expected:
|
||||
// - Entity 1: Premium (active) + Pro (scheduled) — unchanged
|
||||
// - Entity 2: Growth (active)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 3: upgrade cancels scheduled downgrade (both entities)")}`, async () => {
|
||||
const customerId = "legacy-upgrade-merged-3";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [wordsItem],
|
||||
});
|
||||
const growth = products.growth({ id: "growth", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, free, premium, growth] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: premium.id, entityIndex: 0 }),
|
||||
s.attach({ productId: premium.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Downgrade entity 1 from Premium to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Downgrade entity 2 from Premium to Pro (scheduled)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
// Verify both entities have scheduled downgrade
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
let entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Upgrade entity 2 to Growth → cancels scheduled Pro, immediate switch
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: growth.id,
|
||||
entity_id: entities[1].id,
|
||||
});
|
||||
|
||||
entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: growth.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Entity 1 should be unchanged: Premium (active) + Pro (scheduled)
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: pro.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 4: Upgrade cancels scheduled cancel/free
|
||||
// (from mergedUpgrade4)
|
||||
//
|
||||
// Scenario:
|
||||
// - Pro, Free, Premium products with Words feature
|
||||
// - 2 entities, attach Pro to both
|
||||
// - Downgrade entity 1 from Pro to Free (schedules cancellation)
|
||||
// - Upgrade entity 1 to Premium → cancels scheduled free, immediate switch
|
||||
//
|
||||
// Expected:
|
||||
// - Entity 1: Premium (active)
|
||||
// - Entity 2: Pro (active)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("legacy-upgrade-merged 4: upgrade cancels scheduled cancel/free")}`, async () => {
|
||||
const customerId = "legacy-upgrade-merged-4";
|
||||
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 100 });
|
||||
const pro = products.pro({ id: "pro", items: [wordsItem] });
|
||||
const free = products.base({
|
||||
id: "free",
|
||||
items: [wordsItem],
|
||||
});
|
||||
const premium = products.premium({ id: "premium", items: [wordsItem] });
|
||||
const growth = products.growth({ id: "growth", items: [wordsItem] });
|
||||
|
||||
const { autumnV1, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, free, premium, growth] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
// Downgrade entity 1 from Pro to Free (schedules cancellation)
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
// Verify entity 1 has Pro (active) + Free (scheduled)
|
||||
let entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: free.id,
|
||||
status: "scheduled" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Upgrade entity 1 to Premium → cancels scheduled free, immediate switch
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium.id,
|
||||
entity_id: entities[0].id,
|
||||
});
|
||||
|
||||
entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity1 as any,
|
||||
productId: premium.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
|
||||
// Entity 2 should still have Pro
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity2 as any,
|
||||
productId: pro.id,
|
||||
status: "active" as unknown as CusProductStatus,
|
||||
});
|
||||
});
|
||||
55
server/tests/integration/billing/legacy/toDelete.md
Normal file
55
server/tests/integration/billing/legacy/toDelete.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Files to Delete (migrated to integration/billing/legacy/)
|
||||
|
||||
## Merged Add Tests → legacy/attach/new/legacy-new-merged.test.ts
|
||||
- `server/tests/merged/add/mergedAdd1.test.ts`
|
||||
- `server/tests/merged/add/mergedAdd3.test.ts`
|
||||
|
||||
## Merged Add-on Tests → legacy/attach/new/legacy-addon-merged.test.ts
|
||||
- `server/tests/merged/addOn/mergedAddOn2.test.ts`
|
||||
- `server/tests/merged/addOn/mergedAddOn6.test.ts`
|
||||
|
||||
## Merged Prepaid Tests → legacy/attach/update-quantity/legacy-update-quantity-entities.test.ts
|
||||
- `server/tests/merged/prepaid/mergedPrepaid1.test.ts`
|
||||
- `server/tests/merged/prepaid/mergedPrepaid2.test.ts`
|
||||
- `server/tests/merged/prepaid/mergedPrepaid3.test.ts`
|
||||
|
||||
## Merged Downgrade Tests → legacy/attach/downgrade/legacy-downgrade-merged-schedule.test.ts + legacy-downgrade-merged-clock.test.ts
|
||||
- `server/tests/merged/downgrade/mergedDowngrade1.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade2.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade3.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade4.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade5.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade6.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade8.test.ts`
|
||||
- `server/tests/merged/downgrade/mergedDowngrade9.test.ts`
|
||||
|
||||
## Invoice Action Required Tests → legacy/attach/invoice/ + billing/cron/
|
||||
- `server/tests/integration/billing/invoice-action-required/invoice-action-required1.test.ts`
|
||||
- `server/tests/integration/billing/invoice-action-required/invoice-action-required2.test.ts`
|
||||
- `server/tests/integration/billing/invoice-action-required/invoice-action-required3.test.ts`
|
||||
- `server/tests/integration/billing/invoice-action-required/invoice-action-required4.test.ts`
|
||||
- `server/tests/integration/billing/invoice-action-required/invoice-action-required5.test.ts`
|
||||
- `server/tests/integration/billing/invoice-action-required/new-subscription/new-subscription-action-required1.test.ts`
|
||||
|
||||
## Merged Upgrade Tests → legacy/attach/upgrade/legacy-upgrade-merged.test.ts
|
||||
- `server/tests/merged/upgrade/mergedUpgrade1.test.ts`
|
||||
- `server/tests/merged/upgrade/mergedUpgrade2.test.ts`
|
||||
- `server/tests/merged/upgrade/mergedUpgrade3.test.ts`
|
||||
- `server/tests/merged/upgrade/mergedUpgrade4.test.ts`
|
||||
|
||||
## Merged Group Tests → legacy/attach/group/legacy-group-merged.test.ts
|
||||
- `server/tests/merged/group/mergedGroup1.test.ts`
|
||||
- `server/tests/merged/group/mergedGroup2.test.ts`
|
||||
|
||||
## Merged Trial Tests → legacy/attach/trial/legacy-trial-merged.test.ts
|
||||
- `server/tests/merged/trial/mergedTrial1.test.ts`
|
||||
- `server/tests/merged/trial/mergedTrial2.test.ts`
|
||||
- `server/tests/merged/trial/mergedTrial3.test.ts`
|
||||
|
||||
## Trial Tests → legacy/attach/trial/legacy-trial.test.ts
|
||||
- `server/tests/merged/trial/trial1.test.ts`
|
||||
- `server/tests/merged/trial/trial2.test.ts`
|
||||
|
||||
## Separate Subscription Tests → legacy/attach/separate/legacy-separate.test.ts
|
||||
- `server/tests/merged/separate/separate1.test.ts`
|
||||
- `server/tests/merged/separate/separate2.test.ts`
|
||||
@@ -1,436 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
ApiVersion,
|
||||
BillingInterval,
|
||||
CusExpand,
|
||||
getCusStripeSubCount,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached";
|
||||
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addWeeks } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { CusService } from "@/internal/customers/CusService";
|
||||
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import {
|
||||
constructProduct,
|
||||
constructRawProduct,
|
||||
} from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const paidAddOn = constructRawProduct({
|
||||
id: "addOn",
|
||||
isAddOn: true,
|
||||
items: [
|
||||
constructPriceItem({
|
||||
price: 10,
|
||||
interval: BillingInterval.Month,
|
||||
}),
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 500,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
type: "pro",
|
||||
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 300,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const testCase = "new-billing-subscription1";
|
||||
|
||||
describe(`${chalk.yellowBright("new-billing-subscription: paid product with add on mid cycle. add on should create new sub")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: true,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, paidAddOn],
|
||||
prefix: testCase,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
});
|
||||
|
||||
await advanceTestClock({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: result.testClockId,
|
||||
advanceTo: addWeeks(new Date(), 2).getTime(),
|
||||
waitForSeconds: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach add on and have correct sub", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: paidAddOn.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({
|
||||
fullCus,
|
||||
});
|
||||
|
||||
expect(subCount).toBe(2);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
|
||||
expectProductAttached({
|
||||
customer: customer,
|
||||
product: paidAddOn,
|
||||
});
|
||||
|
||||
const invoices = customer.invoices;
|
||||
expect(invoices.length).toBe(2);
|
||||
expect(invoices[0].total).toBe(10);
|
||||
});
|
||||
|
||||
test("should attach add on again and have 3 subscriptions", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: paidAddOn.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({
|
||||
fullCus,
|
||||
});
|
||||
|
||||
expect(subCount).toBe(3);
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const addOnProduct = customer.products.find((p) => p.id === paidAddOn.id);
|
||||
expect(addOnProduct?.quantity).toBe(2);
|
||||
|
||||
const invoices = customer.invoices;
|
||||
expect(invoices?.length).toBe(3);
|
||||
expect(invoices?.[0].total).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
const premium2 = constructProduct({
|
||||
type: "premium",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const pro2 = constructProduct({
|
||||
type: "pro",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 300,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const testCase2 = "new-billing-subscription2";
|
||||
|
||||
describe(`${chalk.yellowBright("new-billing-subscription: entities with new_billing_sub (max 3 subs)")}`, () => {
|
||||
const customerId = testCase2;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
const entities = [
|
||||
{ id: "entity1", name: "Entity 1", feature_id: TestFeature.Users },
|
||||
{ id: "entity2", name: "Entity 2", feature_id: TestFeature.Users },
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: true,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro2, premium2],
|
||||
prefix: testCase2,
|
||||
});
|
||||
|
||||
await autumnV1.entities.create(customerId, entities);
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro2.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach premium to entity1 with new_billing_subscription", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[0].id,
|
||||
product_id: premium2.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(2);
|
||||
|
||||
const entity = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: premium2,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach premium to entity2 with new_billing_subscription", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
entity_id: entities[1].id,
|
||||
product_id: premium2.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(3);
|
||||
|
||||
const entity = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: premium2,
|
||||
});
|
||||
});
|
||||
|
||||
test("should have correct state: customer pro + 2 entity premiums", async () => {
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(3);
|
||||
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId, {
|
||||
expand: [CusExpand.Invoices],
|
||||
});
|
||||
|
||||
const customerPro = customer.products.find((p) => p.id === pro2.id);
|
||||
expect(customerPro).toBeDefined();
|
||||
expect(customerPro?.status).toBe("active");
|
||||
|
||||
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
|
||||
const entity1Premium = entity1.products!.find(
|
||||
(p: any) => p.id === premium2.id,
|
||||
);
|
||||
expect(entity1Premium).toBeDefined();
|
||||
expect(entity1Premium!.status).toBe("active");
|
||||
|
||||
const entity2 = await autumnV1.entities.get(customerId, entities[1].id);
|
||||
const entity2Premium = entity2.products!.find(
|
||||
(p: any) => p.id === premium2.id,
|
||||
);
|
||||
expect(entity2Premium).toBeDefined();
|
||||
expect(entity2Premium!.status).toBe("active");
|
||||
|
||||
console.log(
|
||||
`customer invoices: ${JSON.stringify(customer.invoices, null, 2)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const premium3 = constructProduct({
|
||||
type: "premium",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const pro3 = constructProduct({
|
||||
type: "pro",
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 300,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const testCase3 = "new-billing-subscription3";
|
||||
|
||||
describe(`${chalk.yellowBright("new-billing-subscription: customer upgrade with entity on separate sub")}`, () => {
|
||||
const customerId = testCase3;
|
||||
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||
|
||||
const entity1 = {
|
||||
id: "entity1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: true,
|
||||
attachPm: "success",
|
||||
});
|
||||
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro3, premium3],
|
||||
prefix: testCase3,
|
||||
});
|
||||
|
||||
await autumnV1.entities.create(customerId, [entity1]);
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro3.id,
|
||||
});
|
||||
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
entity_id: entity1.id,
|
||||
product_id: premium3.id,
|
||||
new_billing_subscription: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should have customer pro + entity premium (2 subs)", async () => {
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(2);
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
expectProductAttached({
|
||||
customer,
|
||||
product: pro3,
|
||||
});
|
||||
|
||||
const entity = await autumnV1.entities.get(customerId, entity1.id);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: premium3,
|
||||
});
|
||||
});
|
||||
|
||||
test("should upgrade main customer from pro to premium without affecting entity sub", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: premium3.id,
|
||||
});
|
||||
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
|
||||
expectProductAttached({
|
||||
customer,
|
||||
product: premium3,
|
||||
});
|
||||
|
||||
const proProduct = customer.products.find(
|
||||
(p) => p.id === pro3.id && !p.entity_id,
|
||||
);
|
||||
expect(proProduct).toBeUndefined();
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(2);
|
||||
});
|
||||
|
||||
test("should have correct final state: customer premium + entity premium on separate subs", async () => {
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
const customerPremium = customer.products.find(
|
||||
(p) => p.id === premium3.id && !p.entity_id,
|
||||
);
|
||||
expect(customerPremium).toBeDefined();
|
||||
expect(customerPremium?.status).toBe("active");
|
||||
|
||||
const entity = await autumnV1.entities.get(customerId, entity1.id);
|
||||
const entityProducts = entity.products!;
|
||||
expect(entityProducts.length).toBe(1);
|
||||
|
||||
const entityPremium = entityProducts.find((p: any) => p.id === premium3.id);
|
||||
expect(entityPremium).toBeDefined();
|
||||
expect(entityPremium!.status).toBe("active");
|
||||
|
||||
const fullCus = await CusService.getFull({
|
||||
idOrInternalId: customerId,
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
const subCount = getCusStripeSubCount({ fullCus });
|
||||
expect(subCount).toBe(2);
|
||||
|
||||
const invoices = customer.invoices;
|
||||
expect(invoices).toBeDefined();
|
||||
expect(invoices!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -182,7 +182,6 @@ test.concurrent(`${chalk.yellowBright("uncancel + add trial")}`, async () => {
|
||||
length: trialDays,
|
||||
duration: FreeTrialDuration.Day,
|
||||
card_required: true,
|
||||
unique_fingerprint: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -205,7 +204,6 @@ test.concurrent(`${chalk.yellowBright("uncancel + add trial")}`, async () => {
|
||||
length: trialDays,
|
||||
duration: FreeTrialDuration.Day,
|
||||
card_required: true,
|
||||
unique_fingerprint: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -221,7 +221,6 @@ test.concurrent(`${chalk.yellowBright("error: cancel_action with other params")}
|
||||
free_trial: {
|
||||
length: 7,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: false,
|
||||
card_required: true,
|
||||
},
|
||||
});
|
||||
@@ -356,7 +355,6 @@ test.concurrent(`${chalk.yellowBright("error: cannot pass free_trial when cancel
|
||||
length: 14,
|
||||
duration: FreeTrialDuration.Day,
|
||||
card_required: true,
|
||||
unique_fingerprint: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -148,7 +148,6 @@ test.concurrent(`${chalk.yellowBright("next_cycle_only: extending trial is allow
|
||||
free_trial: {
|
||||
length: 14,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: false,
|
||||
card_required: true,
|
||||
},
|
||||
billing_behavior: "next_cycle_only",
|
||||
|
||||
@@ -237,7 +237,6 @@ test.concurrent(`${chalk.yellowBright("error: one-off adding free trial")}`, asy
|
||||
free_trial: {
|
||||
length: 7,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: false,
|
||||
card_required: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import {
|
||||
expectProductStatusesByOrder,
|
||||
getFullCustomerWithExpired,
|
||||
} from "@tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
@@ -19,10 +23,6 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { cleanupOneOffCustomerProducts } from "@/internal/customers/cusProducts/actions/cleanupOneOff/cleanupOneOff.js";
|
||||
import {
|
||||
expectProductStatusesByOrder,
|
||||
getFullCustomerWithExpired,
|
||||
} from "./utils/oneOffCleanupTestUtils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: Two one-time prepaid, both track to 0, cleanup - first expired
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import {
|
||||
expectProductStatusesByOrder,
|
||||
getFullCustomerWithExpired,
|
||||
} from "@tests/integration/cron/one-off-cleanup/utils/oneOffCleanupTestUtils.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
@@ -24,10 +28,6 @@ import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { cleanupOneOffCustomerProducts } from "@/internal/customers/cusProducts/actions/cleanupOneOff/cleanupOneOff.js";
|
||||
import {
|
||||
expectProductStatusesByOrder,
|
||||
getFullCustomerWithExpired,
|
||||
} from "./utils/oneOffCleanupTestUtils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TEST 1: One-time product with monthly messages, track to 0, attach again - both active
|
||||
|
||||
@@ -180,15 +180,12 @@ test.concurrent(`${chalk.yellowBright("cleanup: entity-isolation-only-same-entit
|
||||
|
||||
// First (Entity 0, oldest): Should be expired (depleted + has newer active on same entity)
|
||||
expect(cusProducts[0].status).toBe(CusProductStatus.Expired);
|
||||
expect(cusProducts[0].internal_entity_id).toBe(entities[0].internal_id);
|
||||
|
||||
// Second (Entity 0, newer): Should stay active (no newer active product exists for this entity)
|
||||
expect(cusProducts[1].status).toBe(CusProductStatus.Active);
|
||||
expect(cusProducts[1].internal_entity_id).toBe(entities[0].internal_id);
|
||||
|
||||
// Third (Entity 1): Should stay active (different entity, isolated)
|
||||
expect(cusProducts[2].status).toBe(CusProductStatus.Active);
|
||||
expect(cusProducts[2].internal_entity_id).toBe(entities[1].internal_id);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { type AppEnv, LegacyVersion, type Organization } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js";
|
||||
import { getAttachPreviewTotal } from "@tests/utils/testAttachUtils/getAttachPreviewTotal.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { getBasePrice } from "@tests/utils/testProductUtils/testProductUtils.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
describe(`${chalk.yellowBright("mergedAdd1: Testing merged subs, with track")}`, () => {
|
||||
const customerId = "mergedAdd1";
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro],
|
||||
prefix: customerId,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should attach pro product", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: "1",
|
||||
});
|
||||
|
||||
const expectedTotal = await getAttachPreviewTotal({
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
entityId: "2",
|
||||
});
|
||||
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: "2",
|
||||
});
|
||||
|
||||
const customer = await autumn.customers.get(customerId);
|
||||
const invoice = customer.invoices;
|
||||
|
||||
expect(invoice[0].total).toBe(expectedTotal);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
|
||||
await timeout(3000);
|
||||
});
|
||||
|
||||
test("should track usage and have correct invoice end of month", async () => {
|
||||
const value1 = 110000;
|
||||
const value2 = 310000;
|
||||
const values = [value1, value2];
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Words,
|
||||
value: value1,
|
||||
entity_id: "1",
|
||||
});
|
||||
|
||||
await autumn.track({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Words,
|
||||
value: value2,
|
||||
entity_id: "2",
|
||||
});
|
||||
|
||||
await timeout(3000);
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const expectedTotal = await getExpectedInvoiceTotal({
|
||||
customerId,
|
||||
productId: pro.id,
|
||||
usage: [{ featureId: TestFeature.Words, value: values[i] }],
|
||||
onlyIncludeUsage: true,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
total += expectedTotal;
|
||||
}
|
||||
|
||||
const basePrice = getBasePrice({ product: pro });
|
||||
|
||||
const customer = await autumn.customers.get(customerId);
|
||||
const invoice = customer.invoices;
|
||||
expect(invoice[0].total).toBe(basePrice * 2 + total);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "3",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedAdd3";
|
||||
describe(`${chalk.yellowBright(`${testCase}: Testing scheduled, and merged add to subscription`)}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Entity 3",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [premium, pro],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (const op of ops) {
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
|
||||
for (const result of op.results) {
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructFeatureItem,
|
||||
constructPrepaidItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import {
|
||||
constructProduct,
|
||||
constructRawProduct,
|
||||
} from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Credits })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const billingUnits = 100;
|
||||
const addOn = constructRawProduct({
|
||||
id: "addOn",
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
billingUnits,
|
||||
price: 10,
|
||||
}),
|
||||
],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: pro, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
otherProducts: [pro],
|
||||
},
|
||||
|
||||
{
|
||||
entityId: "2",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: pro, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 5,
|
||||
},
|
||||
],
|
||||
otherProducts: [pro],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: pro, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 2,
|
||||
},
|
||||
],
|
||||
otherProducts: [pro],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedAddOn2";
|
||||
describe(`${chalk.yellowBright("mergedAddOn2: testing add ons between multiple entities")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, addOn],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should run operations", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
options: op.options,
|
||||
otherProducts: op.otherProducts,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
|
||||
for (const result of op.results) {
|
||||
// const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
const cus = await autumn.customers.get(customerId);
|
||||
expectProductAttached({
|
||||
customer: cus,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructFeatureItem,
|
||||
constructPrepaidItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import {
|
||||
constructProduct,
|
||||
constructRawProduct,
|
||||
} from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Credits })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Credits })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const billingUnits = 100;
|
||||
const addOn = constructRawProduct({
|
||||
id: "addOn",
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
billingUnits,
|
||||
price: 10,
|
||||
}),
|
||||
],
|
||||
isAddOn: true,
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
otherProducts: [premium],
|
||||
},
|
||||
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 5,
|
||||
},
|
||||
],
|
||||
otherProducts: [premium],
|
||||
},
|
||||
{
|
||||
entityId: "3",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "3",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: pro, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
otherProducts: [pro],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: addOn,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: addOn, status: CusProductStatus.Active },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 5,
|
||||
},
|
||||
],
|
||||
otherProducts: [premium],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedAddOn6";
|
||||
describe(`${chalk.yellowBright("mergedAddOn6: testing update add on quantities on many entities")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, addOn, premium],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Entity 3",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should run operations", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
options: op.options,
|
||||
otherProducts: op.otherProducts,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
|
||||
for (const result of op.results) {
|
||||
// const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
const cus = await autumn.customers.get(customerId);
|
||||
expectProductAttached({
|
||||
customer: cus,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
test("should update prepaid quantity for entity 1 and 2", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: addOn,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
entityId: "1",
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
otherProducts: [premium],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// Premium, Premium
|
||||
// Pro, Pro
|
||||
// Premium, Premium
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const init = [
|
||||
{ entityId: "1", product: premium }, // upgrade to premium
|
||||
{ entityId: "2", product: premium }, // upgrade to premium
|
||||
];
|
||||
|
||||
const ops1 = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Renew
|
||||
const ops2 = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
];
|
||||
|
||||
describe(`${chalk.yellowBright("mergedDowngrade1: Testing merged subs, downgrade 2 pros")}`, () => {
|
||||
const customerId = "mergedDowngrade1";
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium],
|
||||
prefix: customerId,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (const op of init) {
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const op of ops1) {
|
||||
test(`should downgrade entity ${op.entityId} to pro and have correct sub + schedule`, async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pro.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const op of ops2) {
|
||||
test(`should renew entity ${op.entityId} and have correct sub + schedule`, async () => {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructArrearItem,
|
||||
constructFeatureItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// Premium
|
||||
// Free
|
||||
// Free, Premium
|
||||
// Free, Pro
|
||||
|
||||
const free = constructProduct({
|
||||
id: "free",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
shouldBeCanceled: true,
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade2";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade2: Testing merged subs, downgrade free 1, add premium 2")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium, free],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
shouldBeCanceled: op.shouldBeCanceled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
// return;
|
||||
|
||||
test("should advance test clock and have correct products for entity 1 & 2", async () => {
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
});
|
||||
|
||||
const results = [
|
||||
{ entityId: "1", product: free, status: CusProductStatus.Active },
|
||||
{ entityId: "2", product: pro, status: CusProductStatus.Active },
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
const entity = await autumn.entities.get(customerId, result.entityId);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
const products = entity.products.filter(
|
||||
(p: any) => p.group === result.product.group,
|
||||
);
|
||||
expect(products.length).toBe(1);
|
||||
}
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
});
|
||||
|
||||
test("should attach premium to entity 1 (which is free) and have correct products", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: premium,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructArrearItem,
|
||||
constructFeatureItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// Pro, Pro
|
||||
// Free, Premium
|
||||
|
||||
const free = constructProduct({
|
||||
id: "free",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: pro, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade3";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade3: Testing merged subs, pro 1, pro 2, downgrade free pro 1, upgrade pro 2 ")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium, free],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// PremiumAnnual, Premium
|
||||
// PremiumAnnual, Pro
|
||||
|
||||
const premiumAnnual = constructProduct({
|
||||
id: "premiumAnnual",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
isAnnual: true,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premiumAnnual,
|
||||
results: [{ product: premiumAnnual, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade4";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade4: Testing advance clock, schedule activates")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium, premiumAnnual],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("should advance test clock and have correct premium downgraded for entity 2", async () => {
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
});
|
||||
|
||||
// 1. Check that only
|
||||
const results = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premiumAnnual,
|
||||
status: CusProductStatus.Active,
|
||||
},
|
||||
{ entityId: "2", product: pro, status: CusProductStatus.Active },
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
const entity = await autumn.entities.get(customerId, result.entityId);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
const products = entity.products.filter(
|
||||
(p: any) => p.group === result.product.group,
|
||||
);
|
||||
expect(products.length).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import {
|
||||
constructArrearItem,
|
||||
constructFeatureItem,
|
||||
} from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// Premium, Premium
|
||||
// Free, Free
|
||||
// Pro, Free
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const free = constructProduct({
|
||||
id: "free",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
shouldBeCanceled: true,
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// entityId: "2",
|
||||
// product: free,
|
||||
// results: [{ product: free, status: CusProductStatus.Active }],
|
||||
// },
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade5";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade5: Testing downgrade to free")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, free, premium],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (const op of ops) {
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
entityId: op.entityId,
|
||||
shouldBeCanceled: op.shouldBeCanceled,
|
||||
});
|
||||
|
||||
for (const result of op.results) {
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// it("should advance test clock and have correct premium downgraded for entity 2", async function () {
|
||||
// await advanceToNextInvoice({
|
||||
// stripeCli,
|
||||
// testClockId,
|
||||
// });
|
||||
|
||||
// // 1. Check that only
|
||||
// const results = [
|
||||
// {
|
||||
// entityId: "1",
|
||||
// product: premiumAnnual,
|
||||
// status: CusProductStatus.Active,
|
||||
// },
|
||||
// { entityId: "2", product: premium, status: CusProductStatus.Active },
|
||||
// ];
|
||||
|
||||
// for (const result of results) {
|
||||
// const entity = await autumn.entities.get(customerId, result.entityId);
|
||||
// expectProductAttached({
|
||||
// customer: entity,
|
||||
// product: result.product,
|
||||
// status: result.status,
|
||||
// });
|
||||
|
||||
// const products = entity.products.filter(
|
||||
// (p: any) => p.group == result.product.group
|
||||
// );
|
||||
// expect(products.length).to.equal(1);
|
||||
// }
|
||||
// });
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
// OPERATIONS:
|
||||
// Growth, Growth
|
||||
// Free
|
||||
// Pro
|
||||
// Premium
|
||||
// Free
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const free = constructProduct({
|
||||
id: "free",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "free",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
const growth = constructProduct({
|
||||
id: "growth",
|
||||
items: [constructFeatureItem({ featureId: TestFeature.Words })],
|
||||
type: "growth",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: growth,
|
||||
results: [{ product: growth, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: growth,
|
||||
results: [{ product: growth, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: growth, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: growth, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [
|
||||
{ product: growth, status: CusProductStatus.Active },
|
||||
{ product: premium, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: free,
|
||||
results: [
|
||||
{ product: growth, status: CusProductStatus.Active },
|
||||
{ product: free, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade6";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade6: Testing downgrade changes")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, free, premium, growth],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (const op of ops) {
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entities,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
|
||||
for (const result of op.results) {
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const premiumAnnual = constructProduct({
|
||||
id: "premiumAnnual",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
isAnnual: true,
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
// const init = [
|
||||
// { entityId: "1", product: premiumAnnual }, // upgrade to premium
|
||||
// { entityId: "2", product: premium }, // upgrade to premium
|
||||
// ];
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premiumAnnual,
|
||||
results: [{ product: premiumAnnual, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premiumAnnual, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: premiumAnnual,
|
||||
results: [{ product: premiumAnnual, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade8";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade8: Testing merged subs, downgrade 2 monthly + annual")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium, premiumAnnual],
|
||||
prefix: customerId,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
customer_id: customerId,
|
||||
product_id: op.product.id,
|
||||
entity_id: op.entityId,
|
||||
});
|
||||
|
||||
const entity = await autumn.entities.get(customerId, op.entityId);
|
||||
for (const result of op.results) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: result.product,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
entity.products.filter((p: any) => p.group === premium.group).length,
|
||||
).toBe(op.results.length);
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
// UNCOMMENT FROM HERE
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const premiumAnnual = constructProduct({
|
||||
id: "premiumAnnual",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "premium",
|
||||
isAnnual: true,
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [constructArrearItem({ featureId: TestFeature.Words })],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
// const init = [
|
||||
// { entityId: "1", product: premiumAnnual }, // upgrade to premium
|
||||
// { entityId: "2", product: premium }, // upgrade to premium
|
||||
// ];
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premiumAnnual,
|
||||
results: [{ product: premiumAnnual, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
},
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premiumAnnual, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [
|
||||
{ product: premium, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedDowngrade9";
|
||||
describe(`${chalk.yellowBright("mergedDowngrade9: Testing merged subs, downgrade 2 monthly + annual & advance test clock")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium, premiumAnnual],
|
||||
prefix: customerId,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
|
||||
await autumn.entities.create(customerId, entities);
|
||||
});
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
test(`should attach ${op.product.id} to entity ${op.entityId}`, async () => {
|
||||
try {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: op.entityId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("should advance test clock and have correct products for entity 1 & 2", async () => {
|
||||
const results = [
|
||||
{
|
||||
entityId: "1",
|
||||
products: [
|
||||
{ product: premiumAnnual, status: CusProductStatus.Active },
|
||||
{ product: pro, status: CusProductStatus.Scheduled },
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
products: [{ product: pro, status: CusProductStatus.Active }],
|
||||
},
|
||||
];
|
||||
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
const entity = await autumn.entities.get(customerId, result.entityId);
|
||||
for (const product of result.products) {
|
||||
expectProductAttached({
|
||||
customer: entity,
|
||||
product: product.product,
|
||||
status: product.status,
|
||||
});
|
||||
}
|
||||
const products = entity.products.filter(
|
||||
(p: any) => p.group === premium.group,
|
||||
);
|
||||
expect(products.length).toBe(result.products.length);
|
||||
}
|
||||
});
|
||||
|
||||
test("should attach premium to entity 2 and have correct products", async () => {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: premium,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: "2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import {
|
||||
cusProductToPrices,
|
||||
type FullCusProduct,
|
||||
isFixedPrice,
|
||||
} from "@autumn/shared";
|
||||
|
||||
export const cusProductToSubIds = ({
|
||||
cusProducts,
|
||||
}: {
|
||||
cusProducts: FullCusProduct[];
|
||||
}) => {
|
||||
return [...new Set(cusProducts.flatMap((cp) => cp.subscription_ids || []))];
|
||||
};
|
||||
|
||||
export const cpToPrice = ({
|
||||
cp,
|
||||
type,
|
||||
}: {
|
||||
cp: FullCusProduct;
|
||||
type: "base" | "arrear" | "cont" | "prepaid";
|
||||
}) => {
|
||||
const prices = cusProductToPrices({ cusProduct: cp });
|
||||
return prices.find((p) => isFixedPrice(p));
|
||||
};
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
import { isFreeProduct } from "@/internal/products/productUtils.js";
|
||||
import { formatUnixToDateTime, nullish } from "@/utils/genUtils.js";
|
||||
import type { TestContext } from "../../utils/testInitUtils/createTestContext.js";
|
||||
import { cusProductToSubIds } from "../mergeUtils.test.js";
|
||||
|
||||
const compareActualItems = async ({
|
||||
actualItems,
|
||||
@@ -201,7 +200,9 @@ export const expectSubToBeCorrect = async ({
|
||||
let cusProducts = fullCus.customer_products;
|
||||
|
||||
if (!subId) {
|
||||
const subIds = cusProductToSubIds({ cusProducts });
|
||||
const subIds = [
|
||||
...new Set(cusProducts.flatMap((cp) => cp.subscription_ids || [])),
|
||||
];
|
||||
subId = subIds[0];
|
||||
expect(subIds.length).toBe(1);
|
||||
} else {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const billingUnits = 100;
|
||||
const creditItem = constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
price: 10,
|
||||
billingUnits,
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [creditItem],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [creditItem],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Update prepaid quantity (increase)
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Update prepaid quantity (decrease)
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedPrepaid1";
|
||||
describe(`${chalk.yellowBright("mergedPrepaid1: Testing merged subs, upgrade 1 & 2 to pro, add premium 2")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should run operations", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
try {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: op.entityId,
|
||||
options: op.options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { beforeAll, describe, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
const billingUnits = 100;
|
||||
const creditItem = constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
price: 10,
|
||||
billingUnits,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.None,
|
||||
},
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [creditItem],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [creditItem],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Update prepaid quantity (increase)
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
// // Update prepaid quantity (decrease)
|
||||
// {
|
||||
// entityId: "2",
|
||||
// product: pro,
|
||||
// results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
// options: [
|
||||
// {
|
||||
// feature_id: TestFeature.Credits,
|
||||
// quantity: billingUnits * 1,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
const testCase = "mergedPrepaid2";
|
||||
describe(`${chalk.yellowBright("mergedPrepaid2: Testing merged subs, upgrade 1 & 2 to pro, add premium 2")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should run operations", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
try {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: op.entityId,
|
||||
options: op.options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should have correct balances after update", async () => {
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
// PREPAID WITH DOWNGRADE (SCHEDULED...)
|
||||
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type AppEnv,
|
||||
CusProductStatus,
|
||||
LegacyVersion,
|
||||
OnDecrease,
|
||||
OnIncrease,
|
||||
type Organization,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { addPrefixToProducts } from "@tests/utils/testProductUtils/testProductUtils.js";
|
||||
import chalk from "chalk";
|
||||
import type { Stripe } from "stripe";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { expectSubToBeCorrect } from "../mergeUtils/expectSubCorrect.js";
|
||||
|
||||
const billingUnits = 100;
|
||||
const creditItem = constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
price: 10,
|
||||
billingUnits,
|
||||
config: {
|
||||
on_increase: OnIncrease.ProrateImmediately,
|
||||
on_decrease: OnDecrease.ProrateImmediately,
|
||||
},
|
||||
});
|
||||
|
||||
const premium = constructProduct({
|
||||
id: "premium",
|
||||
items: [creditItem],
|
||||
type: "premium",
|
||||
});
|
||||
|
||||
const pro = constructProduct({
|
||||
id: "pro",
|
||||
items: [creditItem],
|
||||
type: "pro",
|
||||
});
|
||||
|
||||
const ops = [
|
||||
{
|
||||
entityId: "1",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
entityId: "2",
|
||||
product: premium,
|
||||
results: [{ product: premium, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Update prepaid quantity (increase)
|
||||
{
|
||||
entityId: "1",
|
||||
product: pro,
|
||||
results: [{ product: pro, status: CusProductStatus.Active }],
|
||||
options: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
quantity: billingUnits * 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const testCase = "mergedPrepaid3";
|
||||
describe(`${chalk.yellowBright("mergedPrepaid3: Testing merged subs, upgrade 1 & 2 to premium, downgrade 1 to pro")}`, () => {
|
||||
const customerId = testCase;
|
||||
const autumn: AutumnInt = new AutumnInt({ version: LegacyVersion.v1_4 });
|
||||
|
||||
let stripeCli: Stripe;
|
||||
let testClockId: string;
|
||||
let curUnix: number;
|
||||
let db: DrizzleCli;
|
||||
let org: Organization;
|
||||
let env: AppEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [pro, premium],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const res = await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
customerData: {},
|
||||
attachPm: "success",
|
||||
withTestClock: true,
|
||||
});
|
||||
|
||||
stripeCli = ctx.stripeCli;
|
||||
db = ctx.db;
|
||||
org = ctx.org;
|
||||
env = ctx.env;
|
||||
testClockId = res.testClockId!;
|
||||
});
|
||||
|
||||
const entities = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Entity 1",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Entity 2",
|
||||
feature_id: TestFeature.Users,
|
||||
},
|
||||
];
|
||||
|
||||
test("should run operations", async () => {
|
||||
await autumn.entities.create(customerId, entities);
|
||||
|
||||
for (let index = 0; index < ops.length; index++) {
|
||||
const op = ops[index];
|
||||
try {
|
||||
await attachAndExpectCorrect({
|
||||
autumn,
|
||||
customerId,
|
||||
product: op.product,
|
||||
stripeCli,
|
||||
db,
|
||||
org,
|
||||
env,
|
||||
entityId: op.entityId,
|
||||
options: op.options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Operation failed: ${op.entityId} ${op.product.id}, index: ${index}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should have correct products after update", async () => {
|
||||
await advanceToNextInvoice({
|
||||
stripeCli,
|
||||
testClockId,
|
||||
});
|
||||
|
||||
const entity1 = await autumn.entities.get(customerId, "1");
|
||||
expectProductAttached({
|
||||
customer: entity1,
|
||||
product: pro,
|
||||
entityId: "1",
|
||||
});
|
||||
|
||||
await expectSubToBeCorrect({
|
||||
db,
|
||||
customerId,
|
||||
org,
|
||||
env,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { test } from "bun:test";
|
||||
import {
|
||||
expectProductNotTrialing,
|
||||
expectProductTrialing,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
@@ -44,33 +48,30 @@ test(`${chalk.yellowBright("autumn-checkout: pro trial → premium (no trial) -
|
||||
actions: [s.attach({ productId: "pro-trial" })],
|
||||
});
|
||||
|
||||
// Get customer state while trialing
|
||||
const customerBefore = await autumnV1.customers.get(customerId);
|
||||
console.log("customer during trial:", {
|
||||
products: customerBefore.products?.map(
|
||||
(p: { id: string; name: string | null; status?: string; trial_ends_at?: string | null }) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
status: p.status,
|
||||
trial_ends_at: p.trial_ends_at,
|
||||
}),
|
||||
),
|
||||
// Verify pro-trial is trialing before upgrade
|
||||
await expectProductTrialing({
|
||||
customerId,
|
||||
productId: proTrial.id,
|
||||
});
|
||||
|
||||
// 1. Preview the upgrade from trial to non-trial
|
||||
const upgradePreview = await autumnV1.billing.previewAttach({
|
||||
await autumnV1.billing.previewAttach({
|
||||
customer_id: customerId,
|
||||
product_id: `premium_${customerId}`,
|
||||
redirect_mode: "always",
|
||||
});
|
||||
console.log("upgrade from trial preview:", upgradePreview);
|
||||
|
||||
// 2. Perform the upgrade with redirect_mode: "always" (Autumn checkout URL)
|
||||
// This should end the trial and start billing immediately
|
||||
const upgradeResult = await autumnV1.billing.attach({
|
||||
await autumnV1.billing.attach({
|
||||
customer_id: customerId,
|
||||
product_id: `premium_${customerId}`,
|
||||
redirect_mode: "always",
|
||||
});
|
||||
console.log("upgrade from trial result:", upgradeResult);
|
||||
|
||||
// Verify premium is active and not trialing after upgrade
|
||||
await expectProductNotTrialing({
|
||||
customerId,
|
||||
productId: premium.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import {
|
||||
ApiVersion,
|
||||
ApiVersionClass,
|
||||
AppEnv,
|
||||
BillingVersion,
|
||||
type Feature,
|
||||
type FullCusProduct,
|
||||
type FullProduct,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
import type Stripe from "stripe";
|
||||
import { logger } from "@/external/logtail/logtailUtils";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { BillingContext } from "@autumn/shared";
|
||||
import { stripeCustomers } from "../stripe/customers";
|
||||
import { customers } from "./customers";
|
||||
|
||||
@@ -74,6 +75,7 @@ const createBilling = ({
|
||||
customPrices: [],
|
||||
customEnts: [],
|
||||
isCustom: false,
|
||||
billingVersion: BillingVersion.V2,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BillingVersion,
|
||||
CollectionMethod,
|
||||
CusProductStatus,
|
||||
type FeatureOptions,
|
||||
@@ -65,6 +66,7 @@ const create = ({
|
||||
customer_entitlements: customerEntitlements,
|
||||
product: product ?? (products.createFull({ id: productId }) as FullProduct),
|
||||
free_trial: null,
|
||||
billing_version: BillingVersion.V2,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -56,32 +56,6 @@ const onCheckoutBoth = ({
|
||||
received_by: RewardReceivedBy.All,
|
||||
});
|
||||
|
||||
/**
|
||||
* Referral program that triggers on checkout, reward goes to redeemer only
|
||||
* @param id - Program ID (default: "checkout-redeemer")
|
||||
* @param rewardId - The reward ID to use
|
||||
* @param productIds - Product IDs that trigger this program
|
||||
* @param maxRedemptions - Max number of redemptions (default: 2)
|
||||
*/
|
||||
const onCheckoutRedeemer = ({
|
||||
id = "checkout-redeemer",
|
||||
rewardId,
|
||||
productIds,
|
||||
maxRedemptions = 2,
|
||||
}: {
|
||||
id?: string;
|
||||
rewardId: string;
|
||||
productIds: string[];
|
||||
maxRedemptions?: number;
|
||||
}): CreateRewardProgram => ({
|
||||
id,
|
||||
when: RewardTriggerEvent.Checkout,
|
||||
product_ids: productIds,
|
||||
internal_reward_id: rewardId,
|
||||
max_redemptions: maxRedemptions,
|
||||
received_by: RewardReceivedBy.Redeemer,
|
||||
});
|
||||
|
||||
/**
|
||||
* Referral program that triggers immediately on customer creation, reward goes to referrer
|
||||
* @param id - Program ID (default: "immediate-referrer")
|
||||
@@ -131,7 +105,6 @@ const onCustomerCreationBoth = ({
|
||||
export const referralPrograms = {
|
||||
onCheckoutReferrer,
|
||||
onCheckoutBoth,
|
||||
onCheckoutRedeemer,
|
||||
onCustomerCreationReferrer,
|
||||
onCustomerCreationBoth,
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AttachFunctionResponseSchema = z.object({
|
||||
checkout_url: z.string().optional(),
|
||||
checkout_url: z.string().nullish(),
|
||||
message: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { UsageModel } from "../productV2Models/productItemModels/productIte
|
||||
import type { AttachBranch } from "./attachEnums/AttachBranch.js";
|
||||
import type { AttachFunction } from "./attachEnums/AttachFunction.js";
|
||||
|
||||
export interface PreviewLineItem {
|
||||
export interface LegacyPreviewLineItem {
|
||||
amount?: number | undefined;
|
||||
description: string;
|
||||
price: string;
|
||||
@@ -19,11 +19,11 @@ export interface AttachPreview {
|
||||
options: any;
|
||||
new_items: any;
|
||||
due_today: {
|
||||
line_items: PreviewLineItem[];
|
||||
line_items: LegacyPreviewLineItem[];
|
||||
total: number;
|
||||
};
|
||||
due_next_cycle: {
|
||||
line_items: PreviewLineItem[];
|
||||
line_items: LegacyPreviewLineItem[];
|
||||
due_at: number;
|
||||
};
|
||||
free_trial?: FreeTrial | null;
|
||||
|
||||
@@ -174,7 +174,7 @@ export const isCustomerProductOnStripeSubscriptionSchedule = ({
|
||||
stripeSubscriptionScheduleId,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
stripeSubscriptionScheduleId: string | null;
|
||||
stripeSubscriptionScheduleId?: string;
|
||||
}) => {
|
||||
if (!stripeSubscriptionScheduleId) return false;
|
||||
return customerProduct.scheduled_ids?.includes(stripeSubscriptionScheduleId);
|
||||
|
||||
@@ -214,7 +214,7 @@ class CustomerProductChecker {
|
||||
onStripeSchedule({
|
||||
stripeSubscriptionScheduleId,
|
||||
}: {
|
||||
stripeSubscriptionScheduleId: string | null;
|
||||
stripeSubscriptionScheduleId: string | undefined;
|
||||
}) {
|
||||
this.pendingPredicates.push(
|
||||
(cp) =>
|
||||
|
||||
@@ -64,6 +64,8 @@ export function useUpdateSubscriptionRequestBody({
|
||||
const inputQuantity = prepaidOptions[featureId];
|
||||
const initialQuantity = initialPrepaidOptions[featureId];
|
||||
const billingUnits = item.billing_units ?? 1;
|
||||
const includedUsage =
|
||||
typeof item.included_usage === "number" ? item.included_usage : 0;
|
||||
|
||||
if (
|
||||
inputQuantity !== undefined &&
|
||||
@@ -73,7 +75,7 @@ export function useUpdateSubscriptionRequestBody({
|
||||
) {
|
||||
return {
|
||||
feature_id: featureId,
|
||||
quantity: inputQuantity * billingUnits,
|
||||
quantity: inputQuantity * billingUnits + includedUsage,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -91,11 +93,13 @@ export function useUpdateSubscriptionRequestBody({
|
||||
) {
|
||||
const inputQuantity = prepaidOptions[item.feature_id];
|
||||
const billingUnits = item.billing_units ?? 1;
|
||||
const includedUsage =
|
||||
typeof item.included_usage === "number" ? item.included_usage : 0;
|
||||
|
||||
if (inputQuantity !== undefined && inputQuantity !== null) {
|
||||
options.push({
|
||||
feature_id: item.feature_id,
|
||||
quantity: inputQuantity * billingUnits,
|
||||
quantity: inputQuantity * billingUnits + includedUsage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +96,16 @@ export function useUpdateSubscriptionBodyBuilder(
|
||||
};
|
||||
}
|
||||
|
||||
const includedUsage =
|
||||
typeof prepaidItem.included_usage === "number"
|
||||
? prepaidItem.included_usage
|
||||
: 0;
|
||||
|
||||
return {
|
||||
feature_id: featureId,
|
||||
quantity: new Decimal(quantity || 0)
|
||||
.mul(prepaidItem.billing_units || 1)
|
||||
.add(includedUsage)
|
||||
.toNumber(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AttachProductSheetTrigger() {
|
||||
const feature = features.features.find((f) => f.id === entity?.feature_id);
|
||||
|
||||
const handleClick = () => {
|
||||
setSheet({ type: "attach-product-v2" });
|
||||
setSheet({ type: "attach-product" });
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user