fix: migration bugs
This commit is contained in:
3
server/src/external/autumn/autumnCli.ts
vendored
3
server/src/external/autumn/autumnCli.ts
vendored
@@ -985,6 +985,7 @@ export class AutumnInt {
|
||||
id: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
no_billing_changes?: boolean;
|
||||
}): Promise<Migration> => {
|
||||
const data = await this.post(`/migrations.create`, params);
|
||||
return data as Migration;
|
||||
@@ -1000,6 +1001,7 @@ export class AutumnInt {
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
retry_failed?: boolean;
|
||||
no_billing_changes?: boolean;
|
||||
};
|
||||
}): Promise<Migration> => {
|
||||
const data = await this.post(`/migrations.update`, params);
|
||||
@@ -1013,6 +1015,7 @@ export class AutumnInt {
|
||||
id: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
no_billing_changes?: boolean;
|
||||
}): Promise<Migration> => {
|
||||
try {
|
||||
await this.post(`/migrations.delete`, { id: params.id });
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
EntInterval,
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
|
||||
import { priceToBillingMethod } from "@shared/utils/productUtils/priceUtils/convertPriceUtils";
|
||||
|
||||
export type CustomerEntitlementCarryIdentity = {
|
||||
internalFeatureId: string;
|
||||
interval: string;
|
||||
intervalCount: number;
|
||||
entityFeatureId: string | null;
|
||||
billingMethod: string | null;
|
||||
};
|
||||
|
||||
export const carryIdentityToKey = (
|
||||
identity: CustomerEntitlementCarryIdentity,
|
||||
) =>
|
||||
[
|
||||
identity.internalFeatureId,
|
||||
identity.interval,
|
||||
identity.intervalCount,
|
||||
identity.entityFeatureId ?? "",
|
||||
identity.billingMethod ?? "",
|
||||
].join(":");
|
||||
|
||||
export const customerEntitlementToCarryIdentity = ({
|
||||
customerEntitlement,
|
||||
customerProduct,
|
||||
}: {
|
||||
customerEntitlement: FullCustomerEntitlement;
|
||||
customerProduct: FullCusProduct;
|
||||
}): CustomerEntitlementCarryIdentity => {
|
||||
const customerEntitlementWithProduct = {
|
||||
...customerEntitlement,
|
||||
customer_product: customerProduct,
|
||||
} satisfies FullCusEntWithFullCusProduct;
|
||||
const customerPrice = cusEntToCusPrice({
|
||||
cusEnt: customerEntitlementWithProduct,
|
||||
});
|
||||
const entitlement = customerEntitlement.entitlement;
|
||||
|
||||
return {
|
||||
internalFeatureId: entitlement.internal_feature_id,
|
||||
interval:
|
||||
customerPrice?.price.config.interval ??
|
||||
entitlement.interval ??
|
||||
EntInterval.Lifetime,
|
||||
intervalCount: entitlement.interval_count ?? 1,
|
||||
entityFeatureId: entitlement.entity_feature_id ?? null,
|
||||
billingMethod: priceToBillingMethod({ price: customerPrice?.price }) ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
carryIdentityToKey,
|
||||
customerEntitlementToCarryIdentity,
|
||||
} from "./carryIdentity";
|
||||
import { customerProductWithOnlyEntitlements } from "./projectCustomerProductForCarry";
|
||||
|
||||
export type CustomerProductCarryGroup = {
|
||||
fromCustomerProduct: FullCusProduct;
|
||||
toCustomerProduct: FullCusProduct;
|
||||
};
|
||||
|
||||
const addToGroup = <T>(groups: Map<string, T[]>, key: string, value: T) => {
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(value);
|
||||
return;
|
||||
}
|
||||
|
||||
groups.set(key, [value]);
|
||||
};
|
||||
|
||||
const groupCustomerEntitlementsByCarryIdentity = ({
|
||||
customerProduct,
|
||||
customerEntitlements,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
}) => {
|
||||
const customerEntitlementsByKey = new Map<
|
||||
string,
|
||||
FullCustomerEntitlement[]
|
||||
>();
|
||||
|
||||
for (const customerEntitlement of customerEntitlements) {
|
||||
const key = carryIdentityToKey(
|
||||
customerEntitlementToCarryIdentity({
|
||||
customerEntitlement,
|
||||
customerProduct,
|
||||
}),
|
||||
);
|
||||
addToGroup(customerEntitlementsByKey, key, customerEntitlement);
|
||||
}
|
||||
|
||||
return customerEntitlementsByKey;
|
||||
};
|
||||
|
||||
export const getCustomerProductCarryGroups = ({
|
||||
fromCustomerProduct,
|
||||
toCustomerProduct,
|
||||
fromCustomerEntitlements,
|
||||
}: {
|
||||
fromCustomerProduct: FullCusProduct;
|
||||
toCustomerProduct: FullCusProduct;
|
||||
fromCustomerEntitlements: FullCustomerEntitlement[];
|
||||
}): CustomerProductCarryGroup[] => {
|
||||
const toEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
|
||||
customerProduct: toCustomerProduct,
|
||||
customerEntitlements: toCustomerProduct.customer_entitlements,
|
||||
});
|
||||
const fromEntitlementsByKey = groupCustomerEntitlementsByCarryIdentity({
|
||||
customerProduct: fromCustomerProduct,
|
||||
customerEntitlements: fromCustomerEntitlements,
|
||||
});
|
||||
|
||||
return Array.from(fromEntitlementsByKey.entries()).flatMap(
|
||||
([key, fromEntitlements]) => {
|
||||
const toEntitlements = toEntitlementsByKey.get(key);
|
||||
if (!toEntitlements) return [];
|
||||
|
||||
return {
|
||||
fromCustomerProduct: customerProductWithOnlyEntitlements({
|
||||
customerProduct: fromCustomerProduct,
|
||||
customerEntitlements: fromEntitlements,
|
||||
}),
|
||||
toCustomerProduct: customerProductWithOnlyEntitlements({
|
||||
customerProduct: toCustomerProduct,
|
||||
customerEntitlements: toEntitlements,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./carryIdentity";
|
||||
export * from "./customerProductCarryGroups";
|
||||
export * from "./projectCustomerProductForCarry";
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
type FullCusEntWithFullCusProduct,
|
||||
type FullCusProduct,
|
||||
type FullCustomerEntitlement,
|
||||
type FullCustomerPrice,
|
||||
} from "@autumn/shared";
|
||||
import { cusEntToCusPrice } from "@shared/utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
|
||||
|
||||
const customerPricesForCustomerEntitlements = ({
|
||||
customerProduct,
|
||||
customerEntitlements,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
}): FullCustomerPrice[] => {
|
||||
const customerPricesById = new Map<string, FullCustomerPrice>();
|
||||
|
||||
for (const customerEntitlement of customerEntitlements) {
|
||||
const customerPrice = cusEntToCusPrice({
|
||||
cusEnt: {
|
||||
...customerEntitlement,
|
||||
customer_product: customerProduct,
|
||||
} satisfies FullCusEntWithFullCusProduct,
|
||||
});
|
||||
if (!customerPrice) continue;
|
||||
|
||||
customerPricesById.set(customerPrice.id, customerPrice);
|
||||
}
|
||||
|
||||
return Array.from(customerPricesById.values());
|
||||
};
|
||||
|
||||
export const customerProductWithOnlyEntitlements = ({
|
||||
customerProduct,
|
||||
customerEntitlements,
|
||||
}: {
|
||||
customerProduct: FullCusProduct;
|
||||
customerEntitlements: FullCustomerEntitlement[];
|
||||
}): FullCusProduct => ({
|
||||
...customerProduct,
|
||||
customer_prices: customerPricesForCustomerEntitlements({
|
||||
customerProduct,
|
||||
customerEntitlements,
|
||||
}),
|
||||
customer_entitlements: customerEntitlements,
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { FullCusProduct, PatchContext } from "@autumn/shared";
|
||||
|
||||
export const getPatchCarryCustomerProduct = ({
|
||||
patchContext,
|
||||
}: {
|
||||
patchContext: PatchContext;
|
||||
}): FullCusProduct => {
|
||||
const deletedEntitlementIds = new Set(
|
||||
patchContext.deleteCustomerEntitlements.map(
|
||||
(customerEntitlement) => customerEntitlement.entitlement.id,
|
||||
),
|
||||
);
|
||||
const deletedCustomerPriceIds = new Set(
|
||||
patchContext.deleteCustomerPrices.map((customerPrice) => customerPrice.id),
|
||||
);
|
||||
|
||||
return {
|
||||
...patchContext.originalCustomerProduct,
|
||||
customer_prices:
|
||||
patchContext.originalCustomerProduct.customer_prices.filter(
|
||||
(customerPrice) =>
|
||||
deletedCustomerPriceIds.has(customerPrice.id) ||
|
||||
(customerPrice.price.entitlement_id
|
||||
? deletedEntitlementIds.has(customerPrice.price.entitlement_id)
|
||||
: false),
|
||||
),
|
||||
customer_entitlements: patchContext.deleteCustomerEntitlements,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./applyCustomerProductItemsPatch";
|
||||
export * from "./getPatchCarryCustomerProduct";
|
||||
export * from "./initPatchCustomerProduct";
|
||||
export * from "./initPatchedCustomerEntitlementsAndPrices";
|
||||
|
||||
@@ -6,10 +6,11 @@ import type {
|
||||
} from "@autumn/shared";
|
||||
import { enrichEntitlementsWithFeatures } from "@shared/utils/productUtils/entUtils/enrichEntitlement";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { getCustomerProductCarryGroups } from "@/internal/billing/v2/utils/initFullCustomerProduct/carryExisting";
|
||||
import { applyExistingStatesToCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/applyExisting/applyExistingStatesToCustomerProduct";
|
||||
import { initCustomerEntitlement } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerEntitlement/initCustomerEntitlement";
|
||||
import { initCustomerPrice } from "@/internal/billing/v2/utils/initFullCustomerProduct/initCustomerPrice";
|
||||
import { getPatchCarryCustomerProduct } from "./getPatchCarryCustomerProduct";
|
||||
import { applyOneOffPrepaidCarryOvers } from "../../handleOneOffPrepaidCarryOvers/applyOneOffPrepaidCarryOvers";
|
||||
|
||||
type PatchInitBillingContext = Pick<
|
||||
UpdateSubscriptionBillingContext,
|
||||
@@ -86,23 +87,35 @@ export const initPatchedCustomerEntitlementsAndPrices = ({
|
||||
customer_prices: customerPrices,
|
||||
customer_entitlements: customerEntitlements,
|
||||
};
|
||||
const carryCustomerProduct = getPatchCarryCustomerProduct({ patchContext });
|
||||
|
||||
applyExistingStatesToCustomerProduct({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
customerProduct: customerProductWithNewItemsOnly,
|
||||
existingUsagesConfig: skipExistingUsageCarry
|
||||
? undefined
|
||||
: {
|
||||
fromCustomerProduct: carryCustomerProduct,
|
||||
carryAllConsumableFeatures: true,
|
||||
},
|
||||
existingRolloversConfig: {
|
||||
fromCustomerProduct: carryCustomerProduct,
|
||||
},
|
||||
const carryGroups = getCustomerProductCarryGroups({
|
||||
fromCustomerProduct: patchContext.originalCustomerProduct,
|
||||
toCustomerProduct: customerProductWithNewItemsOnly,
|
||||
fromCustomerEntitlements: patchContext.deleteCustomerEntitlements,
|
||||
});
|
||||
|
||||
for (const carryGroup of carryGroups) {
|
||||
applyExistingStatesToCustomerProduct({
|
||||
ctx,
|
||||
fullCustomer,
|
||||
customerProduct: carryGroup.toCustomerProduct,
|
||||
existingUsagesConfig: skipExistingUsageCarry
|
||||
? undefined
|
||||
: {
|
||||
fromCustomerProduct: carryGroup.fromCustomerProduct,
|
||||
carryAllConsumableFeatures: true,
|
||||
},
|
||||
existingRolloversConfig: {
|
||||
fromCustomerProduct: carryGroup.fromCustomerProduct,
|
||||
},
|
||||
});
|
||||
|
||||
applyOneOffPrepaidCarryOvers({
|
||||
oldCustomerProduct: carryGroup.fromCustomerProduct,
|
||||
newCustomerProduct: carryGroup.toCustomerProduct,
|
||||
fullCustomer,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
customerPrices: customerProductWithNewItemsOnly.customer_prices,
|
||||
customerEntitlements: customerProductWithNewItemsOnly.customer_entitlements,
|
||||
|
||||
@@ -12,6 +12,7 @@ const PatchMigrationBody = z.object({
|
||||
filter: MigrationFilterSchema.nullable().optional(),
|
||||
operations: OperationsSchema.nullable().optional(),
|
||||
retry_failed: z.boolean().optional(),
|
||||
no_billing_changes: z.boolean().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,12 @@ export const updateMigration = async ({
|
||||
updates: Partial<
|
||||
Pick<
|
||||
MigrationInsert,
|
||||
"id" | "filter" | "operations" | "prepared_state" | "retry_failed"
|
||||
| "id"
|
||||
| "filter"
|
||||
| "operations"
|
||||
| "prepared_state"
|
||||
| "retry_failed"
|
||||
| "no_billing_changes"
|
||||
>
|
||||
>;
|
||||
}): Promise<Migration | null> => {
|
||||
|
||||
@@ -6,10 +6,7 @@ import type {
|
||||
} from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan.js";
|
||||
import {
|
||||
assertStripePlanNoCharges,
|
||||
hasStripePlanActions,
|
||||
} from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
|
||||
import { assertStripePlanNoCharges } from "@/internal/billing/v2/providers/stripe/errors/assertStripePlanNoCharges.js";
|
||||
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.js";
|
||||
import { MigrationOperationError } from "@/internal/migrations/v2/operations/errors/index.js";
|
||||
import type { MigrateCustomerContext } from "@/internal/migrations/v2/operations/types/index.js";
|
||||
@@ -61,20 +58,22 @@ export const evaluateMigrateCustomerStripe = async ({
|
||||
billingContexts: UpdateSubscriptionBillingContext[];
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
}): Promise<MigrateCustomerBillingPlan> => {
|
||||
if (context.migration.no_billing_changes === true) {
|
||||
return {
|
||||
autumn: autumnBillingPlan,
|
||||
stripe: {},
|
||||
stripeBillingPlans: [],
|
||||
};
|
||||
}
|
||||
|
||||
const stripeBillingPlans: MigrateCustomerStripeBillingPlan[] = [];
|
||||
|
||||
for (const [subscriptionId, billingContext] of contextBySubscriptionId({
|
||||
billingContexts,
|
||||
})) {
|
||||
const shouldValidateForcedNoBillingChanges =
|
||||
context.migration.no_billing_changes === true;
|
||||
const evaluationContext = shouldValidateForcedNoBillingChanges
|
||||
? { ...billingContext, skipBillingChanges: false }
|
||||
: billingContext;
|
||||
|
||||
const stripeBillingPlan = await evaluateStripeBillingPlan({
|
||||
ctx,
|
||||
billingContext: evaluationContext,
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
});
|
||||
appendMigrationBillingLog({
|
||||
@@ -84,7 +83,7 @@ export const evaluateMigrateCustomerStripe = async ({
|
||||
logStripeBillingPlan({
|
||||
ctx: logCtx,
|
||||
stripeBillingPlan,
|
||||
billingContext: evaluationContext,
|
||||
billingContext,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -101,20 +100,6 @@ export const evaluateMigrateCustomerStripe = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
shouldValidateForcedNoBillingChanges &&
|
||||
hasStripePlanActions(stripeBillingPlan)
|
||||
) {
|
||||
throw new MigrationOperationError({
|
||||
code: "unsupported_operation_input",
|
||||
operationType: "update_plan",
|
||||
field: "no_billing_changes",
|
||||
message:
|
||||
"Migration no_billing_changes=true was set, but update_plan produced Stripe mutations",
|
||||
details: { subscriptionId },
|
||||
});
|
||||
}
|
||||
|
||||
stripeBillingPlans.push({
|
||||
subscriptionId,
|
||||
billingContext,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* TDD coverage for migration draft CRUD used by the dashboard.
|
||||
*
|
||||
* Red-failure mode: PATCH strips `updates.no_billing_changes`, so the
|
||||
* saved migration does not match the dashboard toggle.
|
||||
*
|
||||
* Green-success criteria: PATCH persists `no_billing_changes` like create.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("migrations.update: persists no_billing_changes from dashboard PATCH")}`,
|
||||
async () => {
|
||||
const customerId = "migrations-update-no-billing";
|
||||
const migrationId = `${customerId}-mig`;
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer()],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2_2.migrationsV2.deleteAndCreate({ id: migrationId });
|
||||
const updated = await autumnV2_2.migrationsV2.update({
|
||||
id: migrationId,
|
||||
updates: { no_billing_changes: true },
|
||||
});
|
||||
|
||||
expect(updated.no_billing_changes).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
ProductItemInterval,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
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";
|
||||
import {
|
||||
constructFeatureItem,
|
||||
constructPrepaidItem,
|
||||
} from "@/utils/scriptUtils/constructItem";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
type BalanceBreakdown = NonNullable<
|
||||
ApiCustomerV5["balances"][string]["breakdown"]
|
||||
>[number];
|
||||
|
||||
const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) =>
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
interval: ProductItemInterval.Day,
|
||||
});
|
||||
|
||||
const oneOffPrepaidCredits = ({
|
||||
includedUsage = 0,
|
||||
billingUnits = 100,
|
||||
price = 10,
|
||||
}: {
|
||||
includedUsage?: number;
|
||||
billingUnits?: number;
|
||||
price?: number;
|
||||
} = {}) =>
|
||||
constructPrepaidItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
billingUnits,
|
||||
price,
|
||||
isOneOff: true,
|
||||
});
|
||||
|
||||
const getBucket = ({
|
||||
customer,
|
||||
billingMethod,
|
||||
resetInterval,
|
||||
}: {
|
||||
customer: ApiCustomerV5;
|
||||
billingMethod?: BillingMethod;
|
||||
resetInterval?: ResetInterval | null;
|
||||
}): BalanceBreakdown => {
|
||||
const bucket = customer.balances[TestFeature.Credits]?.breakdown?.find(
|
||||
(candidate) => {
|
||||
if (
|
||||
billingMethod &&
|
||||
candidate.price?.billing_method !== billingMethod
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (resetInterval === null) return candidate.reset === null;
|
||||
if (resetInterval) return candidate.reset?.interval === resetInterval;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
expect(bucket).toBeDefined();
|
||||
return bucket!;
|
||||
};
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: daily and monthly credits carry separately when both are updated")}`, async () => {
|
||||
const customerId = "migration-update-items-daily-monthly-carry";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-daily-monthly-carry-plan",
|
||||
items: [
|
||||
dailyCredits({ includedUsage: 50 }),
|
||||
items.monthlyCredits({ includedUsage: 100 }),
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
interval: null,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 20,
|
||||
interval: ResetInterval.Day,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 60,
|
||||
interval: ResetInterval.Month,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 70,
|
||||
interval: ResetInterval.OneOff,
|
||||
});
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: base.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: base.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: ProductItemInterval.Day,
|
||||
},
|
||||
included: 80,
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 280,
|
||||
usage: 100,
|
||||
breakdown: {
|
||||
[ResetInterval.Day]: { included_grant: 80, remaining: 50, usage: 30 },
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
|
||||
[ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 },
|
||||
},
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid and usage-based credits carry by billing method")}`, async () => {
|
||||
const customerId = "migration-update-items-billing-method-carry";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-billing-method-carry-plan",
|
||||
items: [
|
||||
items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
}),
|
||||
items.consumable({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 50,
|
||||
price: 0.1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: 300 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const initialCustomer =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const prepaidBucket = getBucket({
|
||||
customer: initialCustomer,
|
||||
billingMethod: BillingMethod.Prepaid,
|
||||
});
|
||||
const usageBasedBucket = getBucket({
|
||||
customer: initialCustomer,
|
||||
billingMethod: BillingMethod.UsageBased,
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 250,
|
||||
balance_id: prepaidBucket.id,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 30,
|
||||
balance_id: usageBasedBucket.id,
|
||||
});
|
||||
const invoiceCountBefore =
|
||||
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
|
||||
0;
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
billing_method: BillingMethod.UsageBased,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 330,
|
||||
usage: 70,
|
||||
breakdown: {
|
||||
[BillingMethod.Prepaid]: {
|
||||
included_grant: 200,
|
||||
prepaid_grant: 100,
|
||||
remaining: 250,
|
||||
usage: 50,
|
||||
},
|
||||
[BillingMethod.UsageBased]: {
|
||||
included_grant: 100,
|
||||
remaining: 80,
|
||||
usage: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: one-off prepaid balance survives alongside monthly carry")}`, async () => {
|
||||
const customerId = "migration-update-items-one-off-prepaid-carry";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-one-off-prepaid-carry-plan",
|
||||
items: [
|
||||
items.monthlyCredits({ includedUsage: 100 }),
|
||||
oneOffPrepaidCredits({ includedUsage: 0, billingUnits: 100 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: 200 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const initialCustomer =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const monthlyBucket = getBucket({
|
||||
customer: initialCustomer,
|
||||
resetInterval: ResetInterval.Month,
|
||||
});
|
||||
const oneOffBucket = getBucket({
|
||||
customer: initialCustomer,
|
||||
billingMethod: BillingMethod.Prepaid,
|
||||
resetInterval: ResetInterval.OneOff,
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 60,
|
||||
balance_id: monthlyBucket.id,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 150,
|
||||
balance_id: oneOffBucket.id,
|
||||
});
|
||||
const invoiceCountBefore =
|
||||
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
|
||||
0;
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
billing_method: BillingMethod.Prepaid,
|
||||
interval: BillingInterval.OneOff,
|
||||
},
|
||||
included: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 335,
|
||||
usage: 40,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
|
||||
[BillingMethod.Prepaid]: {
|
||||
included_grant: 175,
|
||||
prepaid_grant: 0,
|
||||
remaining: 175,
|
||||
usage: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
type ApiEntityV2,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
ProductItemInterval,
|
||||
ResetInterval,
|
||||
} from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
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";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem";
|
||||
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
|
||||
|
||||
const dailyCredits = ({ includedUsage = 50 }: { includedUsage?: number } = {}) =>
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
interval: ProductItemInterval.Day,
|
||||
});
|
||||
|
||||
const lifetimeCredits = ({
|
||||
includedUsage = 50,
|
||||
}: {
|
||||
includedUsage?: number;
|
||||
} = {}) =>
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage,
|
||||
interval: null,
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: removes daily credits while monthly usage carry stays scoped")}`, async () => {
|
||||
const customerId = "migration-update-items-credits-daily-remove";
|
||||
const base = products.base({
|
||||
id: "migration-update-items-credits-daily-remove-plan",
|
||||
items: [
|
||||
dailyCredits({ includedUsage: 50 }),
|
||||
items.monthlyCredits({ includedUsage: 100 }),
|
||||
lifetimeCredits({ includedUsage: 100 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [s.customer(), s.products({ list: [base] })],
|
||||
actions: [s.billing.attach({ productId: base.id })],
|
||||
});
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 20,
|
||||
interval: ResetInterval.Day,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 60,
|
||||
interval: ResetInterval.Month,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 70,
|
||||
interval: ResetInterval.OneOff,
|
||||
});
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: base.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: base.id },
|
||||
customize: {
|
||||
remove_items: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: ResetInterval.Day,
|
||||
},
|
||||
],
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectCustomerProducts({ customer, active: [base.id] });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 230,
|
||||
usage: 70,
|
||||
planId: base.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 160, usage: 40 },
|
||||
[ResetInterval.OneOff]: { included_grant: 100, remaining: 70, usage: 30 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
customer.balances[TestFeature.Credits]?.breakdown?.some(
|
||||
(bucket) => bucket.reset?.interval === ResetInterval.Day,
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: prepaid credits keep prepaid bucket beside lifetime credits")}`, async () => {
|
||||
const customerId = "migration-update-items-prepaid-credits";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-prepaid-credits-plan",
|
||||
items: [
|
||||
items.prepaid({
|
||||
featureId: TestFeature.Credits,
|
||||
includedUsage: 100,
|
||||
billingUnits: 100,
|
||||
price: 10,
|
||||
}),
|
||||
lifetimeCredits({ includedUsage: 50 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({
|
||||
productId: pro.id,
|
||||
options: [{ feature_id: TestFeature.Credits, quantity: 300 }],
|
||||
}),
|
||||
s.track({ featureId: TestFeature.Credits, value: 125, timeout: 2000 }),
|
||||
],
|
||||
});
|
||||
const invoiceCountBefore =
|
||||
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
|
||||
0;
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 225,
|
||||
usage: 125,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[BillingMethod.Prepaid]: {
|
||||
included_grant: 200,
|
||||
prepaid_grant: 100,
|
||||
remaining: 175,
|
||||
usage: 125,
|
||||
},
|
||||
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
customer.balances[TestFeature.Credits]?.breakdown?.filter(
|
||||
(bucket) => bucket.reset?.interval === ResetInterval.OneOff,
|
||||
).length,
|
||||
).toBe(1);
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: customer plan monthly credits and addon lifetime credits stay separate")}`, async () => {
|
||||
const customerId = "migration-update-items-addon-lifetime-credits";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-addon-lifetime-credits-pro",
|
||||
items: [items.monthlyCredits({ includedUsage: 100 })],
|
||||
});
|
||||
const addon = products.recurringAddOn({
|
||||
id: "migration-update-items-addon-lifetime-credits-addon",
|
||||
items: [lifetimeCredits({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro, addon] }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id }),
|
||||
s.billing.attach({ productId: addon.id }),
|
||||
],
|
||||
});
|
||||
const invoiceCountBefore =
|
||||
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
|
||||
0;
|
||||
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 80,
|
||||
interval: ResetInterval.Month,
|
||||
});
|
||||
await autumnV2.balances.update({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Credits,
|
||||
current_balance: 400,
|
||||
interval: ResetInterval.OneOff,
|
||||
});
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectCustomerProducts({ customer, active: [pro.id, addon.id] });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 580,
|
||||
usage: 120,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 180, usage: 20 },
|
||||
[ResetInterval.OneOff]: {
|
||||
included_grant: 500,
|
||||
remaining: 400,
|
||||
usage: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_items: entity-level credits are migrated per entity product")}`, async () => {
|
||||
const customerId = "migration-update-items-entity-credits";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-items-entity-credits-plan",
|
||||
items: [
|
||||
items.monthlyCredits({ includedUsage: 100 }),
|
||||
lifetimeCredits({ includedUsage: 50 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx, entities } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
s.entities({ count: 2, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
|
||||
s.billing.attach({ productId: pro.id, entityIndex: 1 }),
|
||||
s.track({
|
||||
featureId: TestFeature.Credits,
|
||||
value: 30,
|
||||
entityIndex: 0,
|
||||
timeout: 2000,
|
||||
}),
|
||||
s.track({
|
||||
featureId: TestFeature.Credits,
|
||||
value: 60,
|
||||
entityIndex: 1,
|
||||
timeout: 2000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const invoiceCountBefore =
|
||||
(await autumnV1.customers.get<ApiCustomerV3>(customerId)).invoices?.length ??
|
||||
0;
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
update_items: [
|
||||
{
|
||||
filter: {
|
||||
feature_id: TestFeature.Credits,
|
||||
interval: BillingInterval.Month,
|
||||
},
|
||||
included: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const firstEntity = await autumnV2_2.entities.get<ApiEntityV2>(
|
||||
customerId,
|
||||
entities[0].id,
|
||||
);
|
||||
const secondEntity = await autumnV2_2.entities.get<ApiEntityV2>(
|
||||
customerId,
|
||||
entities[1].id,
|
||||
);
|
||||
|
||||
expectBalanceCorrect({
|
||||
customer: firstEntity,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 220,
|
||||
usage: 30,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 170, usage: 30 },
|
||||
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
|
||||
},
|
||||
});
|
||||
expectBalanceCorrect({
|
||||
customer: secondEntity,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 190,
|
||||
usage: 60,
|
||||
planId: pro.id,
|
||||
breakdown: {
|
||||
[ResetInterval.Month]: { included_grant: 200, remaining: 140, usage: 60 },
|
||||
[ResetInterval.OneOff]: { included_grant: 50, remaining: 50, usage: 0 },
|
||||
},
|
||||
});
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,4 @@
|
||||
/**
|
||||
* TDD coverage for update_items targeting one of several customer entitlements
|
||||
* for the same feature (monthly + lifetime case).
|
||||
*
|
||||
* Contract under test:
|
||||
* New behaviors:
|
||||
* - A `PlanItemFilter` that includes `interval` only matches entitlements
|
||||
* with that interval. Untouched entitlements (different interval) keep
|
||||
* their balance and reset state exactly as-is.
|
||||
* - Usage carried via update_items only applies to the entitlement(s) it
|
||||
* replaced — sibling entitlements for the same feature with usage of
|
||||
* their own do not get double-deducted.
|
||||
* - When a single update_items[i].filter matches multiple customer
|
||||
* entitlements (e.g. feature_id only), all matches are updated.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV3,
|
||||
type ApiCustomerV5,
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* - Existing customer products are patched, not replaced or expired.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiCustomerV5 } from "@autumn/shared";
|
||||
import { BillingMethod } from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
|
||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts";
|
||||
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||
import { expectStripeSubscriptionUnchanged } from "@tests/integration/billing/utils/stripe/expectStripeSubscriptionUnchanged";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
@@ -106,3 +107,64 @@ test.concurrent(`${chalk.yellowBright("migrations update_plan: consumable paid f
|
||||
await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id });
|
||||
await expectStripeSubscriptionCorrect({ ctx, customerId });
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("migrations update_plan: no_billing_changes remove paid feature stays DB-only")}`, async () => {
|
||||
const customerId = "migration-update-paid-remove-no-billing";
|
||||
const pro = products.pro({
|
||||
id: "migration-update-paid-remove-no-billing-plan",
|
||||
items: [items.consumableMessages({ includedUsage: 100, price: 0.1 })],
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2_2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [pro] }),
|
||||
],
|
||||
actions: [s.billing.attach({ productId: pro.id })],
|
||||
});
|
||||
|
||||
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
|
||||
const subsBefore = await ctx.stripeCli.subscriptions.list({
|
||||
customer: customerBefore.stripe_id as string,
|
||||
status: "all",
|
||||
});
|
||||
const subBefore = subsBefore.data.find(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
expect(subBefore).toBeDefined();
|
||||
|
||||
await runUpdatePlanMigration({
|
||||
ctx,
|
||||
migrationClient: autumnV2_2,
|
||||
migrationId: `${customerId}-mig`,
|
||||
customerId,
|
||||
filter: { customer: { plan: { plan_id: pro.id } } },
|
||||
operations: {
|
||||
customer: [
|
||||
{
|
||||
type: "update_plan",
|
||||
plan_filter: { plan_id: pro.id },
|
||||
customize: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
noBillingChanges: true,
|
||||
runOnServer: false,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectCustomerProducts({ customer, active: [pro.id] });
|
||||
expect(customer.balances[TestFeature.Messages]).toBeUndefined();
|
||||
await expectNoExpiredCustomerProducts({ ctx, customerId, productId: pro.id });
|
||||
|
||||
const subAfter = await ctx.stripeCli.subscriptions.retrieve(subBefore!.id);
|
||||
expectStripeSubscriptionUnchanged({ before: subBefore!, after: subAfter });
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
|
||||
count: invoiceCountBefore,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ type MigrationClient = {
|
||||
id: string;
|
||||
filter?: MigrationFilter | null;
|
||||
operations?: Operations | null;
|
||||
no_billing_changes?: boolean;
|
||||
}) => Promise<Migration>;
|
||||
run: (params: { id: string; dry_run?: boolean }) => Promise<{
|
||||
migration_id: string;
|
||||
@@ -60,6 +61,7 @@ export const runUpdatePlanMigration = async ({
|
||||
customerId,
|
||||
filter,
|
||||
operations,
|
||||
noBillingChanges,
|
||||
runOnServer = true,
|
||||
waitFor,
|
||||
timeoutMs = 30_000,
|
||||
@@ -71,6 +73,7 @@ export const runUpdatePlanMigration = async ({
|
||||
customerId: string;
|
||||
filter: MigrationFilter;
|
||||
operations: Operations;
|
||||
noBillingChanges?: boolean;
|
||||
runOnServer?: boolean;
|
||||
waitFor?: () => Promise<unknown>;
|
||||
timeoutMs?: number;
|
||||
@@ -80,6 +83,7 @@ export const runUpdatePlanMigration = async ({
|
||||
id: migrationId,
|
||||
filter,
|
||||
operations,
|
||||
no_billing_changes: noBillingChanges,
|
||||
});
|
||||
|
||||
if (runOnServer) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { InternalError } from "@api/errors/base/InternalError";
|
||||
import { BillingMethod } from "@api/products/components/billingMethod";
|
||||
import type { Feature } from "@models/featureModels/featureModels";
|
||||
import type { EntitlementWithFeature } from "@models/productModels/entModels/entModels";
|
||||
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
|
||||
import { BillingType } from "@models/productModels/priceModels/priceEnums";
|
||||
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||
import {
|
||||
OnDecrease,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
shouldProrate,
|
||||
shouldSkipLineItems,
|
||||
} from "@utils/billingUtils";
|
||||
import { getBillingType } from "@utils/productUtils/priceUtils";
|
||||
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
|
||||
|
||||
// Overload: errorOnNotFound = true → guaranteed Feature
|
||||
@@ -94,3 +97,21 @@ export const priceToProrationConfig = ({
|
||||
shouldCreateReplaceables: shouldCreateReplaceables(prorationBehaviorConfig),
|
||||
};
|
||||
};
|
||||
|
||||
export const priceToBillingMethod = ({
|
||||
price,
|
||||
}: {
|
||||
price?: Price;
|
||||
}): BillingMethod | undefined => {
|
||||
if (!price) return undefined;
|
||||
|
||||
const billingType = getBillingType(price.config);
|
||||
if (billingType === BillingType.UsageInAdvance) return BillingMethod.Prepaid;
|
||||
if (
|
||||
billingType === BillingType.UsageInArrear ||
|
||||
billingType === BillingType.InArrearProrated
|
||||
)
|
||||
return BillingMethod.UsageBased;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user