writing tests for migrations

This commit is contained in:
John Yeo
2025-06-09 17:04:15 +01:00
parent a7a0681457
commit ae568aea95
68 changed files with 2567 additions and 554 deletions

View File

@@ -7,7 +7,7 @@ import {
CusExpand,
ErrCode,
} from "@autumn/shared";
import { CheckParams, TrackParams, UsageParams } from "autumn-js";
import { CheckParams, CheckResult, TrackParams, UsageParams } from "autumn-js";
import { AttachBody } from "@/internal/customers/attach/models/AttachBody.js";
export default class AutumnError extends Error {
@@ -398,7 +398,7 @@ export class AutumnInt {
return data;
};
check = async (params: CheckParams) => {
check = async (params: CheckParams): Promise<CheckResult> => {
const data = await this.post(`/check`, params);
return data;
};
@@ -408,6 +408,16 @@ export class AutumnInt {
return data;
};
migrate = async (params: {
from_product_id: string;
to_product_id: string;
from_version: number;
to_version: number;
}) => {
const data = await this.post(`/migrations`, params);
return data;
};
initStripe = async () => {
await this.post(`/products/all/init_stripe`, {});
};

View File

@@ -1,29 +1,21 @@
import {
AppEnv,
AttachScenario,
CollectionMethod,
CusProductStatus,
ErrCode,
FullCusProduct,
Organization,
} from "@autumn/shared";
import { createStripeCli } from "../utils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { formatUnixToDateTime, notNullish, nullish } from "@/utils/genUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { cancelFutureProductSchedule } from "@/internal/customers/change-product/scheduleUtils.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import {
getWebhookLock,
releaseWebhookLock,
} from "@/external/redis/stripeWebhookLocks.js";
import RecaseError from "@/utils/errorUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { handleSubCanceled } from "./handleSubUpdated/handleSubCanceled.js";
@@ -54,11 +46,6 @@ export const handleSubscriptionUpdated = async ({
env,
});
let fullSub = await stripeCli.subscriptions.retrieve(subscription.id);
let features = await FeatureService.list({
db,
orgId: org.id,
env,
});
let subStatusMap: {
[key: string]: CusProductStatus;

View File

@@ -11,7 +11,7 @@ import { pricingMiddleware } from "@/middleware/pricingMiddleware.js";
import { usageRouter } from "./events/usageRouter.js";
import { invoiceRouter } from "./invoiceRouter.js";
import { entityRouter } from "./entities/entityRouter.js";
import { migrationRouter } from "./migrations/migrationRouter.js";
import { migrationRouter } from "../migrations/migrationRouter.js";
import { redemptionRouter, referralRouter } from "./rewards/referralRouter.js";
import { rewardProgramRouter } from "./rewards/rewardProgramRouter.js";

View File

@@ -0,0 +1,59 @@
import { isFreeProduct } from "@/internal/products/productUtils.js";
import {
AttachBranch,
AttachPreview,
AttachScenario,
FullProduct,
} from "@autumn/shared";
// New = "new", done
// Downgrade = "downgrade",
// Upgrade = "upgrade",
// Cancel = "cancel",
// Renew = "renew",
// Scheduled = "scheduled",
// Active = "active",
// Expired = "expired",
export const getAttachScenario = async ({
preview,
product,
}: {
preview: AttachPreview;
product: FullProduct;
}) => {
let branch = preview.branch;
let attachFunc = preview.func;
if (
branch == AttachBranch.New ||
branch == AttachBranch.OneOff ||
branch == AttachBranch.AddOn
) {
return AttachScenario.New;
}
if (
branch == AttachBranch.MainIsFree ||
branch == AttachBranch.MainIsTrial ||
branch == AttachBranch.Upgrade
) {
return AttachScenario.Upgrade;
}
if (branch == AttachBranch.Downgrade) {
// return AttachScenario.Downgrade;
if (isFreeProduct(product.prices)) {
return AttachScenario.Cancel;
} else {
return AttachScenario.Downgrade;
}
}
if (branch == AttachBranch.Renew) {
return AttachScenario.Renew;
}
return AttachScenario.New;
};

View File

@@ -0,0 +1,153 @@
import { checkToAttachParams } from "@/internal/customers/attach/attachUtils/attachParams/checkToAttachParams.js";
import { FullCusProduct, FullCustomer, FullProduct } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "@/internal/customers/attach/models/AttachBody.js";
import { attachParamsToPreview } from "@/internal/customers/attach/handleAttachPreview/attachParamsToPreview.js";
import {
AttachFunction,
AttachPreview,
CheckProductPreview,
Feature,
Organization,
} from "@autumn/shared";
import { getAttachScenario } from "./attachToCheckPreview/getAttachScenario.js";
import { getProductResponse } from "@/internal/products/productV2Utils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { Decimal } from "decimal.js";
import { notNullish } from "@/utils/genUtils.js";
export const attachToCheckPreview = async ({
preview,
product,
org,
features,
}: {
preview: AttachPreview;
product: FullProduct;
org: Organization;
features: Feature[];
}) => {
// 1. If check
let branch = preview.branch;
let attachFunc = preview.func;
const noOptions = !preview.options || preview.options.length === 0;
if (attachFunc == AttachFunction.CreateCheckout && noOptions) {
return null;
}
let scenario = await getAttachScenario({
preview,
product,
});
let items = preview.due_today?.line_items?.map((item) => {
return {
price: notNullish(item.amount)
? formatAmount({
amount: item.amount!,
org,
minFractionDigits: 2,
maxFractionDigits: 2,
})
: item.price,
description: item.description,
usage_model: item.usage_model,
};
});
let options = preview.options?.map((option: any) => {
return {
...option,
price: new Decimal(option.price).toDecimalPlaces(2).toNumber(),
};
});
let due_today = preview.due_today
? {
price: preview.due_today.total,
currency: org.default_currency || "usd",
}
: undefined;
let due_next_cycle = {
price:
preview.due_next_cycle?.line_items.reduce((acc, item) => {
if (item.amount) {
return acc + item.amount;
}
return acc;
}, 0) || 0,
currency: org.default_currency || "usd",
};
let checkPreview: CheckProductPreview = {
title: "Check",
message: "Check",
scenario,
// Meta
product_id: product.id,
product_name: product.name,
recurring: !isOneOff(product.prices),
error_on_attach: false,
next_cycle_at: preview.due_next_cycle?.due_at,
current_product_name: preview.current_product?.name,
// Otehrs
options,
items,
due_today,
due_next_cycle,
product: getProductResponse({ product, features }),
};
return checkPreview;
};
export const getProductCheckPreview = async ({
req,
customer,
product,
logger,
}: {
req: ExtendedRequest;
customer: FullCustomer;
product: FullProduct;
logger: any;
}) => {
const { org, features } = req;
// Build attach params
const attachParams = await checkToAttachParams({
req,
customer,
product,
logger,
});
const attachBody: AttachBody = {
customer_id: customer.id!,
product_id: product.id,
entity_id: customer.entity?.id,
};
const preview = await attachParamsToPreview({
req,
attachParams,
attachBody,
logger,
});
const checkPreview = await attachToCheckPreview({
preview,
product,
org,
features,
});
return checkPreview;
};

View File

@@ -6,6 +6,7 @@ import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCu
import { getOrgAndFeatures } from "@/internal/orgs/orgUtils.js";
import { getProductResponse } from "@/internal/products/productV2Utils.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { getProductCheckPreview } from "./getProductCheckPreview.js";
export const handleProductCheck = async ({
req,
@@ -40,6 +41,7 @@ export const handleProductCheck = async ({
entityId: entity_id,
entityData: entity_data,
withEntities: true,
}),
ProductService.getFull({
db,
@@ -62,19 +64,28 @@ export const handleProductCheck = async ({
);
let preview = with_preview
? await getAttachPreview({
db,
? await getProductCheckPreview({
req,
customer,
org,
env,
product: product!,
cusProducts,
features,
product,
logger,
shouldFormat: with_preview == "formatted",
})
: undefined;
// let preview = with_preview
// ? await getAttachPreview({
// db,
// customer,
// org,
// env,
// product: product!,
// cusProducts,
// features,
// logger,
// shouldFormat: with_preview == "formatted",
// })
// : undefined;
if (preview) {
preview = {
...preview,

View File

@@ -197,6 +197,7 @@ export const handleUsageEvent = async ({
payload,
logger: console,
db: req.db,
throwError: true,
});
} else {
await addTaskToQueue({

View File

@@ -12,7 +12,12 @@ import {
Replaceable,
InsertReplaceable,
} from "@autumn/shared";
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
import {
formatUnixToDate,
generateId,
notNullish,
nullish,
} from "@/utils/genUtils.js";
import { Customer } from "@autumn/shared";
import { FullProduct } from "@autumn/shared";
@@ -61,13 +66,13 @@ export const initCusPrice = ({
export const initCusProduct = ({
customer,
product,
subscriptionId,
// subscriptionId,
// subscriptionScheduleId,
// lastInvoiceId,
cusProdId,
startsAt,
subscriptionScheduleId,
optionsList,
freeTrial,
lastInvoiceId,
trialEndsAt,
subscriptionStatus,
canceledAt,
@@ -82,13 +87,13 @@ export const initCusProduct = ({
}: {
customer: Customer;
product: FullProduct;
subscriptionId: string | undefined | null;
// subscriptionId: string | undefined | null;
// subscriptionScheduleId?: string | null;
// lastInvoiceId?: string | null;
cusProdId: string;
startsAt?: number;
subscriptionScheduleId?: string | null;
optionsList: FeatureOptions[];
freeTrial: FreeTrial | null;
lastInvoiceId?: string | null;
trialEndsAt?: number | null;
subscriptionStatus?: CusProductStatus;
canceledAt?: number | null;
@@ -122,12 +127,12 @@ export const initCusProduct = ({
? CusProductStatus.Scheduled
: CusProductStatus.Active,
processor: {
type: ProcessorType.Stripe,
subscription_id: subscriptionId,
subscription_schedule_id: subscriptionScheduleId,
last_invoice_id: lastInvoiceId,
},
// processor: {
// type: ProcessorType.Stripe,
// subscription_id: subscriptionId,
// subscription_schedule_id: subscriptionScheduleId,
// last_invoice_id: lastInvoiceId,
// },
starts_at: startsAt || Date.now(),
trial_ends_at: trialEnds,
@@ -267,11 +272,11 @@ export const createFullCusProduct = async ({
db,
attachParams,
startsAt,
subscriptionId,
// subscriptionId,
nextResetAt,
disableFreeTrial = false,
lastInvoiceId = null,
trialEndsAt = null,
trialEndsAt,
subscriptionStatus,
canceledAt = null,
createdAt = null,
@@ -289,12 +294,12 @@ export const createFullCusProduct = async ({
db: DrizzleCli;
attachParams: InsertCusProductParams;
startsAt?: number;
subscriptionId?: string;
// subscriptionId?: string;
nextResetAt?: number;
billLaterOnly?: boolean;
disableFreeTrial?: boolean;
lastInvoiceId?: string | null;
trialEndsAt?: number | null;
trialEndsAt?: number;
subscriptionStatus?: CusProductStatus;
canceledAt?: number | null;
createdAt?: number | null;
@@ -311,22 +316,26 @@ export const createFullCusProduct = async ({
}) => {
disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial;
let { customer, product, prices, entitlements, optionsList, freeTrial, org } =
let { customer, product, prices, entitlements, optionsList, org, freeTrial } =
attachParams;
let attachReplaceables = attachParams.replaceables || [];
// Try to get current cus product or set to null...
let curCusProduct;
try {
curCusProduct = await getExistingCusProduct({
db,
cusProducts: attachParams.cusProducts,
product,
internalCustomerId: customer.internal_id,
internalEntityId: attachParams.internalEntityId,
});
} catch (error) {}
let curCusProduct = await getExistingCusProduct({
db,
cusProducts: attachParams.cusProducts,
product,
internalCustomerId: customer.internal_id,
internalEntityId: attachParams.internalEntityId,
});
freeTrial = disableFreeTrial ? null : freeTrial;
if (carryOverTrial && curCusProduct?.free_trial) {
freeTrial = curCusProduct.free_trial;
trialEndsAt = curCusProduct.trial_ends_at || undefined;
}
let attachReplaceables = attachParams.replaceables || [];
const existingCusProduct = searchCusProducts({
internalProductId: product.internal_id,
@@ -359,6 +368,7 @@ export const createFullCusProduct = async ({
for (const entitlement of entitlements) {
const options = getEntOptions(optionsList, entitlement);
const relatedPrice = getEntRelatedPrice(entitlement, prices);
const now = attachParams.now || Date.now();
const cusEnt: any = initCusEntitlement({
entitlement,
@@ -366,15 +376,17 @@ export const createFullCusProduct = async ({
cusProductId: cusProdId,
options: options || undefined,
nextResetAt,
freeTrial: disableFreeTrial ? null : freeTrial,
freeTrial,
relatedPrice,
// existingCusEnt,
// keepResetIntervals,
trialEndsAt,
anchorToUnix,
entities: attachParams.entities || [],
carryExistingUsages,
curCusProduct: curCusProduct as FullCusProduct,
replaceables: attachReplaceables,
now,
});
cusEnts.push(cusEnt);
@@ -414,21 +426,25 @@ export const createFullCusProduct = async ({
}
// 5. create customer product
if (carryOverTrial && curCusProduct?.free_trial_id) {
freeTrial = curCusProduct.free_trial || null;
trialEndsAt = curCusProduct.trial_ends_at || null;
}
// let freeTrial = disableFreeTrial ? null : freeTrial;
// if (carryOverTrial && curCusProduct?.free_trial_id) {
// logger.info(`Free trial ID: ${curCusProduct.free_trial_id}`);
// logger.info(
// `Trial ends at: ${formatUnixToDate(curCusProduct.trial_ends_at)}`,
// );
// freeTrial = curCusProduct.free_trial || null;
// trialEndsAt = curCusProduct.trial_ends_at || null;
// }
let entityId = customer.entity?.id;
const cusProd = initCusProduct({
cusProdId,
customer,
product,
subscriptionId,
startsAt,
optionsList,
freeTrial: disableFreeTrial ? null : freeTrial,
lastInvoiceId,
trialEndsAt,
subscriptionStatus,
canceledAt,

View File

@@ -18,7 +18,7 @@ import { FeatureOptions } from "@autumn/shared";
import { EntitlementWithFeature } from "@autumn/shared";
import { getResetBalance } from "../cusProducts/cusEnts/cusEntUtils.js";
import { generateId, notNullish } from "@/utils/genUtils.js";
import { formatUnixToDate, generateId, notNullish } from "@/utils/genUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { applyTrialToEntitlement } from "@/internal/products/entitlements/entitlementUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
@@ -26,6 +26,7 @@ import { getNextEntitlementReset } from "@/utils/timeUtils.js";
import { subtractFromUnixTillAligned } from "@/internal/products/prices/billingIntervalUtils.js";
import { UTCDate } from "@date-fns/utc";
import { entitlementLinkedToEntity } from "@/internal/api/entities/entityUtils.js";
import { initNextResetAt } from "../cusProducts/insertCusProduct/initCusEnt/initNextResetAt.js";
export const initCusEntEntities = ({
entitlement,
@@ -71,73 +72,6 @@ export const initCusEntEntities = ({
return newEntities;
};
const initCusEntNextResetAt = ({
entitlement,
nextResetAt,
keepResetIntervals,
existingCusEnt,
freeTrial,
anchorToUnix,
}: {
entitlement: EntitlementWithFeature;
nextResetAt?: number;
keepResetIntervals?: boolean;
existingCusEnt?: FullCustomerEntitlement;
freeTrial: FreeTrial | null;
anchorToUnix?: number;
}) => {
// 1. If entitlement is boolean, or unlimited, or lifetime, then next reset at is null
if (
entitlement.feature.type === FeatureType.Boolean ||
entitlement.allowance_type === AllowanceType.Unlimited ||
entitlement.interval == EntInterval.Lifetime
) {
return null;
}
if (nextResetAt) {
return nextResetAt;
}
// 3. If keepResetIntervals is true, return existing next reset at...
if (keepResetIntervals && existingCusEnt?.next_reset_at) {
return existingCusEnt.next_reset_at;
}
// 4. Calculate next reset at...
let nextResetAtCalculated = null;
let trialEndTimestamp = freeTrialToStripeTimestamp({ freeTrial });
if (
freeTrial &&
applyTrialToEntitlement(entitlement, freeTrial) &&
trialEndTimestamp
) {
nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);
}
let resetInterval = entitlement.interval as EntInterval;
nextResetAtCalculated = getNextEntitlementReset(
nextResetAtCalculated,
resetInterval,
).getTime();
// If anchorToUnix, align next reset at to anchorToUnix...
if (anchorToUnix && nextResetAtCalculated) {
nextResetAtCalculated = subtractFromUnixTillAligned({
targetUnix: anchorToUnix,
originalUnix: nextResetAtCalculated,
});
}
// console.log(
// "NEXT RESET AT",
// format(new Date(nextResetAtCalculated), "dd MMM yyyy HH:mm:ss")
// );
return nextResetAtCalculated;
};
const initCusEntBalance = ({
entitlement,
curCusProduct,
@@ -253,13 +187,15 @@ export const initCusEntitlement = ({
options,
nextResetAt,
relatedPrice,
existingCusEnt,
keepResetIntervals = false,
// existingCusEnt,
// keepResetIntervals = false,
trialEndsAt,
anchorToUnix,
entities,
carryExistingUsages = false,
curCusProduct,
replaceables,
now,
}: {
entitlement: EntitlementWithFeature;
customer: Customer;
@@ -268,13 +204,15 @@ export const initCusEntitlement = ({
options?: FeatureOptions;
nextResetAt?: number;
relatedPrice?: Price;
existingCusEnt?: FullCustomerEntitlement;
keepResetIntervals?: boolean;
// existingCusEnt?: FullCustomerEntitlement;
// keepResetIntervals?: boolean;
trialEndsAt?: number;
anchorToUnix?: number;
entities: Entity[];
carryExistingUsages?: boolean;
curCusProduct?: FullCusProduct;
replaceables: AttachReplaceable[];
now: number;
}) => {
let { newBalance, newEntities } = initCusEntBalance({
entitlement,
@@ -289,13 +227,15 @@ export const initCusEntitlement = ({
(newBalance || 0) -
replaceables.filter((r) => r.ent.id === entitlement.id).length;
let nextResetAtValue = initCusEntNextResetAt({
let nextResetAtValue = initNextResetAt({
entitlement,
nextResetAt,
keepResetIntervals,
existingCusEnt,
// keepResetIntervals,
// existingCusEnt,
trialEndsAt,
freeTrial,
anchorToUnix,
now,
});
// 3. Define expires at (TODO next time...)

View File

@@ -4,11 +4,12 @@ import {
AttachParams,
AttachResultSchema,
} from "../../../cusProducts/AttachParams.js";
import { APIVersion, AttachConfig } from "@autumn/shared";
import { APIVersion, AttachBranch, AttachConfig } from "@autumn/shared";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { SuccessCode } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { handlePaidProduct } from "./handlePaidProduct.js";
import { attachParamsToCurCusProduct } from "../../attachUtils/convertAttachParams.js";
export const handleAddProduct = async ({
req,
@@ -17,7 +18,7 @@ export const handleAddProduct = async ({
config,
}: {
req: ExtendedRequest;
res: any;
res?: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
@@ -41,13 +42,20 @@ export const handleAddProduct = async ({
const batchInsert = [];
for (const product of products) {
let curCusProduct = attachParamsToCurCusProduct({ attachParams });
let anchorToUnix = undefined;
if (curCusProduct && config.branch == AttachBranch.NewVersion) {
anchorToUnix = curCusProduct.created_at;
}
batchInsert.push(
createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
subscriptionId: undefined,
billLaterOnly: true,
carryExistingUsages: config.carryUsage,
anchorToUnix,
logger,
}),
);

View File

@@ -72,8 +72,7 @@ export const getUsageInvoiceItems = async ({
});
if (!sub) continue;
if (interval !== subToAutumnInterval(sub)) continue;
if (interval && interval !== subToAutumnInterval(sub)) continue;
cusEntIds.push(cusEnt.id);

View File

@@ -19,16 +19,16 @@ import {
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { updateStripeSubs } from "./updateStripeSubs.js";
import { updateSubsDiffInt } from "./updateSubsDiffInt.js";
export const handleUpgradeFunction = async ({
export const handleUpgradeDiffInterval = async ({
req,
res,
attachParams,
config,
}: {
req: ExtendedRequest;
res: any;
res?: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
@@ -53,7 +53,7 @@ export const handleUpgradeFunction = async ({
});
logger.info("1. Updating current subscriptions in Stripe");
let { newSubs, invoice, newInvoiceIds } = await updateStripeSubs({
let { newSubs, invoice, newInvoiceIds } = await updateSubsDiffInt({
db: req.db,
curCusProduct,
stripeCli,

View File

@@ -1,17 +1,19 @@
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { createStripeSub } from "@/external/stripe/stripeSubUtils/createStripeSub.js";
import { updateStripeSubscription } from "@/external/stripe/stripeSubUtils/updateStripeSub.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { AttachConfig, FullCusProduct } from "@autumn/shared";
import { subItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { updateCurSchedules } from "./updateCurSchedules.js";
import { getStripeSubItems } from "@/external/stripe/stripeSubUtils/getStripeSubItems.js";
import { addSubItemsToRemove } from "../attachFuncUtils.js";
import { updateStripeSub } from "../../attachUtils/updateStripeSub/updateStripeSub.js";
import {
createUsageInvoiceItems,
resetUsageBalances,
} from "./createUsageInvoiceItems.js";
export const updateStripeSubs = async ({
export const updateSubsDiffInt = async ({
db,
stripeCli,
curCusProduct,
@@ -53,9 +55,18 @@ export const updateStripeSubs = async ({
now: attachParams.now,
});
// 2. Create prorations for single use items
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curCusProduct,
stripeSubs,
logger,
});
// 3. Update current subscription
logger.info("1.2: Updating current subscription");
const { updatedSub, latestInvoice, cusEntIds } = await updateStripeSub({
const { updatedSub, latestInvoice } = await updateStripeSub({
db,
attachParams,
config,
@@ -66,6 +77,12 @@ export const updateStripeSubs = async ({
stripeSubs,
});
await resetUsageBalances({
db,
cusEntIds,
cusProduct: curCusProduct,
});
let newSubs = [updatedSub!];
const newInvoiceIds = latestInvoice ? [latestInvoice.id] : [];

View File

@@ -10,6 +10,7 @@ import { APIVersion, AttachConfig, CusProductStatus } from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { updateSubsByInt } from "./updateSubsSameInt.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
export const handleUpgradeSameInterval = async ({
req,
@@ -18,7 +19,7 @@ export const handleUpgradeSameInterval = async ({
config,
}: {
req: ExtendedRequest;
res: any;
res?: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
@@ -58,31 +59,38 @@ export const handleUpgradeSameInterval = async ({
});
logger.info(`3. Creating new cus product`);
logger.info(
`Anchoring to unix: ${formatUnixToDate(stripeSubs[0].current_period_end * 1000)}`,
);
await createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, attachParams.products[0]),
subscriptionIds: curCusProduct!.subscription_ids || [],
disableFreeTrial: config.disableTrial,
carryExistingUsages: config.carryUsage,
carryOverTrial: config.carryTrial,
anchorToUnix: stripeSubs[0].current_period_end * 1000,
logger,
});
let apiVersion = attachParams.org.api_version || APIVersion.v1;
if (apiVersion >= APIVersion.v1_1) {
res.status(200).json(
AttachResultSchema.parse({
customer_id: attachParams.customer.id,
product_ids: attachParams.products.map((p) => p.id),
code: "updated_product_successfully",
if (res) {
let apiVersion = attachParams.org.api_version || APIVersion.v1;
if (apiVersion >= APIVersion.v1_1) {
res.status(200).json(
AttachResultSchema.parse({
customer_id: attachParams.customer.id,
product_ids: attachParams.products.map((p) => p.id),
code: "updated_product_successfully",
message: `Successfully updated product`,
}),
);
} else {
res.status(200).json({
success: true,
message: `Successfully updated product`,
}),
);
} else {
res.status(200).json({
success: true,
message: `Successfully updated product`,
});
});
}
}
};

View File

@@ -39,7 +39,7 @@ import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { AttachBodySchema } from "./models/AttachBody.js";
import { processAttachBody } from "./attachUtils/processAttachBody.js";
import { processAttachBody } from "./attachUtils/attachParams/processAttachBody.js";
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
import { handleAttach } from "./handleAttach.js";

View File

@@ -0,0 +1,85 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
APIVersion,
Feature,
FullCusProduct,
FullCustomer,
FullProduct,
} from "@autumn/shared";
import { getStripeCusData } from "./attachParamsUtils/getStripeCusData.js";
import { getFreeTrialAfterFingerprint } from "@/internal/products/free-trials/freeTrialUtils.js";
import { orgToVersion } from "@/utils/versionUtils.js";
export const checkToAttachParams = async ({
req,
customer,
product,
logger,
}: {
req: ExtendedRequest;
customer: FullCustomer;
product: FullProduct;
logger: any;
}) => {
const { org, env, db } = req;
const apiVersion =
orgToVersion({
org,
reqApiVersion: req.apiVersion,
}) || APIVersion.v1;
const stripeCli = createStripeCli({ org, env });
let stripeCusData = await getStripeCusData({
stripeCli,
stripeId: customer.processor?.id,
});
let freeTrial = await getFreeTrialAfterFingerprint({
db,
freeTrial: product.free_trial,
fingerprint: customer.fingerprint,
internalCustomerId: customer.internal_id,
multipleAllowed: org.config.multiple_trials,
productId: product.id,
});
const { stripeCus, paymentMethod, now } = stripeCusData;
const attachParams: AttachParams = {
stripeCli,
stripeCus,
now,
paymentMethod,
customer,
products: [product],
optionsList: [],
prices: product.prices,
entitlements: product.entitlements,
freeTrial,
replaceables: [],
// Others
req,
org: req.org,
entities: customer.entities,
features: req.features,
internalEntityId: customer.entity?.internal_id,
cusProducts: customer.customer_products,
// Others
apiVersion,
// successUrl: attachBody.success_url,
// invoiceOnly: attachBody.invoice_only,
// billingAnchor: attachBody.billing_cycle_anchor,
// metadata: attachBody.metadata,
// disableFreeTrial: attachBody.free_trial === false || false,
// checkoutSessionParams: attachBody.checkout_session_params,
// isCustom: attachBody.is_custom,
};
return attachParams;
};

View File

@@ -1,11 +1,10 @@
import { listCusPaymentMethods } from "@/external/stripe/stripeCusUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "../models/AttachBody.js";
import { AttachBody } from "../../models/AttachBody.js";
import { processAttachBody } from "./processAttachBody.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { APIVersion } from "@autumn/shared";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import Stripe from "stripe";
import { AttachParams } from "../../../cusProducts/AttachParams.js";
export const getAttachParams = async ({
req,

View File

@@ -1,5 +1,5 @@
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "../models/AttachBody.js";
import { AttachBody } from "../../models/AttachBody.js";
import RecaseError from "@/utils/errorUtils.js";
import {
CreateFreeTrial,
@@ -12,9 +12,9 @@ import {
} from "@autumn/shared";
import { ProductService } from "@/internal/products/ProductService.js";
import { notNullish } from "@/utils/genUtils.js";
import { getOrCreateCustomer } from "../../cusUtils/getOrCreateCustomer.js";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { mapOptionsList } from "./mapOptionsList.js";
import { getOrCreateCustomer } from "../../../cusUtils/getOrCreateCustomer.js";
import { getExistingCusProducts } from "../../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { mapOptionsList } from "../mapOptionsList.js";
import {
getFreeTrialAfterFingerprint,
handleNewFreeTrial,
@@ -22,12 +22,12 @@ import {
import {
cusProductToEnts,
cusProductToPrices,
} from "../../cusProducts/cusProductUtils/convertCusProduct.js";
} from "../../../cusProducts/cusProductUtils/convertCusProduct.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemUtils/handleNewProductItems.js";
import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils.js";
import { isMainProduct } from "@/internal/products/productUtils/classifyProduct.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { getStripeCusData } from "../getAttachParams/getStripeCusData.js";
import { getStripeCusData } from "./attachParamsUtils/getStripeCusData.js";
const getProductsForAttach = async ({
req,
@@ -134,6 +134,7 @@ const getPricesAndEnts = async ({
fingerprint: customer.fingerprint,
internalCustomerId: customer.internal_id,
multipleAllowed: org.config.multiple_trials,
productId: freeTrialProduct.id,
});
}
@@ -194,6 +195,7 @@ const getPricesAndEnts = async ({
fingerprint: customer.fingerprint,
internalCustomerId: customer.internal_id,
multipleAllowed: org.config.multiple_trials,
productId: product.id,
});
const prodIsMain = isMainProduct({ product: products[0], prices });
@@ -236,6 +238,7 @@ export const processAttachBody = async ({
stripeCli,
stripeId: customer.processor?.id,
});
const { stripeCus, paymentMethod, now } = stripeCusData;
const {
@@ -252,13 +255,6 @@ export const processAttachBody = async ({
products,
});
if (attachBody.free_trial === false) {
throw new RecaseError({
message: "Free trial is not allowed",
code: ErrCode.InvalidRequest,
});
}
return {
customer,
products,

View File

@@ -80,6 +80,8 @@ export const getAttachConfig = async ({
branch == AttachBranch.Downgrade ||
attachBody.free_trial === false;
let carryTrial = branch === AttachBranch.NewVersion;
let sameIntervals = intervalsAreSame({ attachParams });
let config: AttachConfig = {
@@ -92,22 +94,8 @@ export const getAttachConfig = async ({
invoiceOnly: flags.invoiceOnly,
disableMerge: org.config.merge_billing_cycles === false,
sameIntervals,
carryTrial,
};
return { flags, config };
};
const webhookToConfig = ({ org, env }: { org: Organization; env: AppEnv }) => {
const config: AttachConfig = {
branch: AttachBranch.NewVersion, // not needed...
carryUsage: false, // not needed...
onlyCheckout: false,
proration: ProrationBehavior.Immediately,
disableTrial: false,
invoiceOnly: false,
disableMerge: false,
sameIntervals: false,
};
return config;
};

View File

@@ -4,7 +4,7 @@ import {
AttachResultSchema,
} from "../../cusProducts/AttachParams.js";
import { AttachBranch, AttachFunction } from "@autumn/shared";
import { handleUpgradeFunction } from "../attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.js";
import { handleUpgradeDiffInterval } from "../attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.js";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { handleAddProduct } from "../attachFunctions/addProductFlow/handleAddProduct.js";
import { AttachBody } from "../models/AttachBody.js";
@@ -213,7 +213,7 @@ export const runAttachFunction = async ({
}
if (attachFunction == AttachFunction.UpgradeDiffInterval) {
return await handleUpgradeFunction({
return await handleUpgradeDiffInterval({
req,
res,
attachParams,

View File

@@ -103,12 +103,13 @@ export const updateStripeSub = async ({
}
// 2. Create prorations for single use items
let { cusEntIds } = await createUsageInvoiceItems({
let { invoiceItems, cusEntIds } = await createUsageInvoiceItems({
db,
attachParams,
cusProduct: curMainProduct!,
stripeSubs,
logger,
interval: config.sameIntervals ? interval : undefined,
});
// 3. Create prorations for continuous use items

View File

@@ -2,7 +2,7 @@ import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { AttachBodySchema } from "./models/AttachBody.js";
import { getAttachParams } from "./attachUtils/getAttachParams.js";
import { getAttachParams } from "./attachUtils/attachParams/getAttachParams.js";
import { getAttachBranch } from "./attachUtils/getAttachBranch.js";
import { getAttachConfig } from "./attachUtils/getAttachConfig.js";
import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js";

View File

@@ -0,0 +1,103 @@
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBody } from "../models/AttachBody.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { AttachFunction } from "@autumn/shared";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js";
import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js";
import { getNewProductPreview } from "./getNewProductPreview.js";
import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js";
export const attachParamsToPreview = async ({
req,
attachParams,
attachBody,
logger,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
attachBody: AttachBody;
logger: any;
}) => {
// Handle existing product
const branch = await getAttachBranch({
req,
attachBody,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
req,
attachParams,
attachBody,
branch,
});
const func = await getAttachFunction({
branch,
attachParams,
attachBody,
config,
});
logger.info("--------------------------------");
logger.info(`ATTACH PREVIEW (org: ${attachParams.org.id})`);
logger.info(`Branch: ${branch}, Function: ${func}`);
let now = attachParams.now || Date.now();
let preview: any = null;
if (
func == AttachFunction.AddProduct ||
func == AttachFunction.CreateCheckout ||
func == AttachFunction.OneOff
) {
preview = await getNewProductPreview({
attachParams,
now,
logger,
});
}
if (func == AttachFunction.ScheduleProduct) {
preview = await getDowngradeProductPreview({
attachParams,
now,
logger,
});
}
if (
func == AttachFunction.UpgradeDiffInterval ||
func == AttachFunction.UpdatePrepaidQuantity ||
func == AttachFunction.UpgradeSameInterval
) {
preview = await getUpgradeProductPreview({
req,
attachParams,
branch,
now,
});
}
const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({
attachParams,
});
return {
branch,
func,
...preview,
current_product: curMainProduct
? cusProductToProduct({
cusProduct: curMainProduct,
})
: null,
scheduled_product: curScheduledProduct,
};
};

View File

@@ -5,14 +5,9 @@ import {
} from "../attachUtils/convertAttachParams.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
getFirstInterval,
getLastInterval,
} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { notNullish } from "@/utils/genUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import Stripe from "stripe";

View File

@@ -1,6 +1,6 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachParams } from "../attachUtils/getAttachParams.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { AttachBodySchema } from "../models/AttachBody.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
@@ -8,12 +8,11 @@ import { ExtendedResponse } from "@/utils/models/Request.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getNewProductPreview } from "./getNewProductPreview.js";
import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { getUpdateEntsPreview } from "./getUpdateEntsPreview.js";
import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js";
import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js";
import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { AttachFunction } from "@autumn/shared";
import { attachParamsToPreview } from "./attachParamsToPreview.js";
export const handleAttachPreview = (req: any, res: any) =>
routeHandler({
@@ -29,82 +28,92 @@ export const handleAttachPreview = (req: any, res: any) =>
attachBody,
});
// Handle existing product
const branch = await getAttachBranch({
req,
attachBody,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
const attachPreview = await attachParamsToPreview({
req,
attachParams,
attachBody,
branch,
logger,
});
const func = await getAttachFunction({
branch,
attachParams,
attachBody,
config,
});
res.status(200).json(attachPreview);
return;
logger.info("--------------------------------");
logger.info(`ATTACH PREVIEW (org: ${attachParams.org.id})`);
logger.info(`Branch: ${branch}, Function: ${func}`);
// // Handle existing product
// const branch = await getAttachBranch({
// req,
// attachBody,
// attachParams,
// fromPreview: true,
// });
let now = attachParams.now || Date.now();
// const { flags, config } = await getAttachConfig({
// req,
// attachParams,
// attachBody,
// branch,
// });
let preview: any = null;
// const func = await getAttachFunction({
// branch,
// attachParams,
// attachBody,
// config,
// });
if (
func == AttachFunction.AddProduct ||
func == AttachFunction.CreateCheckout ||
func == AttachFunction.OneOff
) {
preview = await getNewProductPreview({
attachParams,
now,
logger,
});
}
// logger.info("--------------------------------");
// logger.info(`ATTACH PREVIEW (org: ${attachParams.org.id})`);
// logger.info(`Branch: ${branch}, Function: ${func}`);
if (func == AttachFunction.ScheduleProduct) {
preview = await getDowngradeProductPreview({
attachParams,
now,
logger,
});
}
// let now = attachParams.now || Date.now();
if (
func == AttachFunction.UpgradeDiffInterval ||
func == AttachFunction.UpdatePrepaidQuantity ||
func == AttachFunction.UpgradeSameInterval
) {
preview = await getUpgradeProductPreview({
req,
attachParams,
branch,
now,
});
}
// let preview: any = null;
const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({
attachParams,
});
// if (
// func == AttachFunction.AddProduct ||
// func == AttachFunction.CreateCheckout ||
// func == AttachFunction.OneOff
// ) {
// preview = await getNewProductPreview({
// attachParams,
// now,
// logger,
// });
// }
res.status(200).json({
branch,
...preview,
current_product: curMainProduct
? cusProductToProduct({
cusProduct: curMainProduct,
})
: null,
scheduled_product: curScheduledProduct,
});
// if (func == AttachFunction.ScheduleProduct) {
// preview = await getDowngradeProductPreview({
// attachParams,
// now,
// logger,
// });
// }
// if (
// func == AttachFunction.UpgradeDiffInterval ||
// func == AttachFunction.UpdatePrepaidQuantity ||
// func == AttachFunction.UpgradeSameInterval
// ) {
// preview = await getUpgradeProductPreview({
// req,
// attachParams,
// branch,
// now,
// });
// }
// const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({
// attachParams,
// });
// res.status(200).json({
// branch,
// ...preview,
// current_product: curMainProduct
// ? cusProductToProduct({
// cusProduct: curMainProduct,
// })
// : null,
// scheduled_product: curScheduledProduct,
// });
},
});

View File

@@ -73,6 +73,7 @@ export type AttachParams = {
export type InsertCusProductParams = {
req?: any;
now?: number;
customer: FullCustomer;
org: Organization;

View File

@@ -0,0 +1,76 @@
import { applyTrialToEntitlement } from "@/internal/products/entitlements/entitlementUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { subtractFromUnixTillAligned } from "@/internal/products/prices/billingIntervalUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import { getNextEntitlementReset } from "@/utils/timeUtils.js";
import {
EntInterval,
AllowanceType,
EntitlementWithFeature,
FeatureType,
FreeTrial,
} from "@autumn/shared";
import { UTCDate } from "@date-fns/utc";
export const initNextResetAt = ({
entitlement,
nextResetAt,
trialEndsAt,
freeTrial,
anchorToUnix,
now,
}: {
entitlement: EntitlementWithFeature;
nextResetAt?: number;
trialEndsAt?: number;
freeTrial: FreeTrial | null;
anchorToUnix?: number;
now: number;
}) => {
// 1. If entitlement is boolean, or unlimited, or lifetime, then next reset at is null
if (
entitlement.feature.type === FeatureType.Boolean ||
entitlement.allowance_type === AllowanceType.Unlimited ||
entitlement.interval == EntInterval.Lifetime
) {
return null;
}
// 2. If nextResetAt is provided, return it...
if (nextResetAt) {
return nextResetAt;
}
// 3. Calculate next reset at...
let nextResetAtCalculated = null;
let trialEndTimestamp = trialEndsAt
? Math.round(trialEndsAt / 1000)
: freeTrial
? freeTrialToStripeTimestamp({ freeTrial, now })
: null;
if (
freeTrial &&
applyTrialToEntitlement(entitlement, freeTrial) &&
trialEndTimestamp
) {
nextResetAtCalculated = new UTCDate(trialEndTimestamp! * 1000);
}
let resetInterval = entitlement.interval as EntInterval;
nextResetAtCalculated = getNextEntitlementReset(
nextResetAtCalculated || new UTCDate(now),
resetInterval,
).getTime();
// If anchorToUnix, align next reset at to anchorToUnix...
if (anchorToUnix && nextResetAtCalculated) {
nextResetAtCalculated = subtractFromUnixTillAligned({
targetUnix: anchorToUnix,
originalUnix: nextResetAtCalculated,
});
}
return nextResetAtCalculated;
};

View File

@@ -106,6 +106,3 @@ devRouter.delete("/api_key/:id", withOrgAuth, async (req: any, res) => {
return;
}
});
// am_live_3ZaPDgqt7K4GkdirAU9oDFT3
// am_test_3ZTRcHdEsdAdoxvSUGL8L35x

View File

@@ -9,7 +9,6 @@ import { featureRouter } from "./features/featureRouter.js";
import { productRouter } from "./products/internalProductRouter.js";
import { devRouter } from "./dev/devRouter.js";
import { cusRouter } from "./customers/internalCusRouter.js";
import { testRouter } from "./test/testRouter.js";
import { onboardingRouter } from "./orgs/onboarding/onboardingRouter.js";
import { handlePostOrg } from "./orgs/handlers/handlePostOrg.js";
import { Autumn } from "autumn-js";
@@ -31,7 +30,6 @@ mainRouter.use("/features", withOrgAuth, featureRouter);
mainRouter.use("/products", withOrgAuth, productRouter);
mainRouter.use("/dev", devRouter);
mainRouter.use("/customers", withOrgAuth, cusRouter);
mainRouter.use("/test", testRouter);
mainRouter.use(
"/api/autumn",

View File

@@ -1,15 +1,24 @@
import { ProductService } from "@/internal/products/ProductService.js";
import RecaseError from "@/utils/errorUtils.js";
import { CusProductStatus, ErrCode } from "@autumn/shared";
import {
BillingType,
CusProductStatus,
ErrCode,
UsagePriceConfig,
} from "@autumn/shared";
import { routeHandler } from "@/utils/routerUtils.js";
import express from "express";
import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js";
import { MigrationService } from "@/internal/migrations/MigrationService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import {
getBillingType,
pricesOnlyOneOff,
} from "@/internal/products/prices/priceUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { findPrepaidPrice } from "../products/prices/priceUtils/findPriceUtils.js";
export const migrationRouter = express.Router();
@@ -19,7 +28,7 @@ migrationRouter.post("", async (req: any, res: any) => {
res,
action: "migrate",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { orgId, env, db } = req;
const { orgId, env, db, features } = req;
const { from_product_id, from_version, to_product_id, to_version } =
req.body;
@@ -68,6 +77,60 @@ migrationRouter.post("", async (req: any, res: any) => {
});
}
if (fromProduct.is_add_on || toProduct.is_add_on) {
throw new RecaseError({
message: `Cannot migrate customers for add on products`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
for (const price of toProduct.prices) {
let billingType = getBillingType(price.config);
if (billingType != BillingType.UsageInAdvance) continue;
let config = price.config as UsagePriceConfig;
let internalFeatureId = config.internal_feature_id;
let feature = features.find((f) => f.internal_id == internalFeatureId)!;
for (const price of fromProduct.prices) {
let prepaidPrice = findPrepaidPrice({
prices: fromProduct.prices,
internalFeatureId,
});
if (!prepaidPrice) {
throw new RecaseError({
message: `New product has prepaid price for feature ${feature.name}, but old product does not, can't perform migration`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
}
if (
!isFreeProduct(fromProduct.prices) &&
isFreeProduct(toProduct.prices)
) {
throw new RecaseError({
message: `Cannot migrate customers from paid product to free product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (
isFreeProduct(fromProduct.prices) &&
!isFreeProduct(toProduct.prices)
) {
throw new RecaseError({
message: `Cannot migrate customers from free product to paid product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// 1. Create migration JOB
let migrationJob = constructMigrationJob({
fromProduct,

View File

@@ -24,6 +24,11 @@ import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { FeatureOptions } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { migrationToAttachParams } from "../migrationUtils/migrationToAttachParams.js";
import { runMigrationAttach } from "../migrationUtils/runMigrationAttach.js";
export const migrateCustomer = async ({
db,
@@ -49,81 +54,110 @@ export const migrateCustomer = async ({
features: Feature[];
}) => {
try {
let cusProducts = await CusProductService.list({
const stripeCli = createStripeCli({ org, env });
let fullCus = await CusService.getFull({
db,
internalCustomerId: customer.internal_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
idOrInternalId: customer.id!,
orgId,
env,
withEntities: true,
});
let entities = await EntityService.list({
// 1. Build req object
let req = {
db,
internalCustomerId: customer.internal_id,
});
orgId,
env,
org,
features,
logtail: logger,
timestamp: Date.now(),
} as ExtendedRequest;
let curCusProduct = cusProducts.find(
const cusProducts = fullCus.customer_products;
const filteredCusProducts = cusProducts.filter(
(cp: FullCusProduct) => cp.product.internal_id == fromProduct.internal_id,
);
if (!curCusProduct) {
logger.error(
`Customer ${customer.id} does not have a ${fromProduct.internal_id} cus product, skipping migration`,
);
return false;
for (const cusProduct of filteredCusProducts) {
const attachParams = await migrationToAttachParams({
req,
stripeCli,
customer: fullCus,
cusProduct,
newProduct: toProduct,
});
await runMigrationAttach({
req,
attachParams,
});
}
let attachParams: AttachParams = {
org,
customer,
products: [toProduct],
prices: toProduct.prices,
entitlements: toProduct.entitlements,
freeTrial: toProduct.free_trial || null,
features,
optionsList: curCusProduct.options,
entities,
cusProducts,
fromMigration: true,
};
// // let cusProducts = await CusProductService.list({
// // db,
// // internalCustomerId: customer.internal_id,
// // inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
// // });
// Get prepaid prices
let prepaidPrices = toProduct.prices.filter(
(price: Price) =>
getBillingType(price.config!) === BillingType.UsageInAdvance,
);
// // let entities = await EntityService.list({
// // db,
// // internalCustomerId: customer.internal_id,
// // });
for (const prepaidPrice of prepaidPrices) {
let config = prepaidPrice.config as UsagePriceConfig;
// let attachParams: AttachParams = {
// org,
// customer,
// products: [toProduct],
// prices: toProduct.prices,
// entitlements: toProduct.entitlements,
// freeTrial: toProduct.free_trial || null,
// features,
// optionsList: curCusProduct.options,
// entities,
// cusProducts,
// fromMigration: true,
// };
let newPrepaid = curCusProduct.options.find(
(option: FeatureOptions) =>
option.internal_feature_id === config.internal_feature_id,
);
// // Get prepaid prices
// let prepaidPrices = toProduct.prices.filter(
// (price: Price) =>
// getBillingType(price.config!) === BillingType.UsageInAdvance,
// );
if (!newPrepaid) {
curCusProduct.options.push({
feature_id: config.feature_id,
internal_feature_id: config.internal_feature_id,
quantity: 0,
});
}
}
// for (const prepaidPrice of prepaidPrices) {
// let config = prepaidPrice.config as UsagePriceConfig;
await handleUpgrade({
req: {
db,
orgId,
env,
logtail: logger,
},
res: null,
attachParams,
curCusProduct,
curFullProduct: fromProduct,
fromReq: false,
carryExistingUsages: true,
prorationBehavior: ProrationBehavior.None,
newVersion: true,
});
// let newPrepaid = curCusProduct.options.find(
// (option: FeatureOptions) =>
// option.internal_feature_id === config.internal_feature_id,
// );
// if (!newPrepaid) {
// curCusProduct.options.push({
// feature_id: config.feature_id,
// internal_feature_id: config.internal_feature_id,
// quantity: 0,
// });
// }
// }
// await handleUpgrade({
// req: {
// db,
// orgId,
// env,
// logtail: logger,
// },
// res: null,
// attachParams,
// curCusProduct,
// curFullProduct: fromProduct,
// fromReq: false,
// carryExistingUsages: true,
// prorationBehavior: ProrationBehavior.None,
// newVersion: true,
// });
return true;
} catch (error: any) {
@@ -131,25 +165,40 @@ export const migrateCustomer = async ({
`Migration failed for customer ${customer.id}, job id: ${migrationJob.id}`,
);
logger.error(error);
if (error instanceof RecaseError) {
logger.error(`Recase error: ${error.message} (${error.code})`);
} else if (error.type === "StripeError") {
logger.error(`Stripe error: ${error.message} (${error.code})`);
} else {
logger.error("Unknown error:", error);
}
// logger.error(
// `Migration failed for customer ${customer.id}, job id: ${migrationJob.id}`,
// );
// logger.error(error);
// if (error instanceof RecaseError) {
// logger.error(`Recase error: ${error.message} (${error.code})`);
// } else if (error.type === "StripeError") {
// logger.error(`Stripe error: ${error.message} (${error.code})`);
// } else {
// logger.error("Unknown error:", error);
// }
await MigrationService.insertError({
db,
data: constructMigrationError({
migrationJobId: migrationJob.id,
internalCustomerId: customer.internal_id,
data: error.data || error,
code: error.code || "unknown",
message: error.message || "unknown",
}),
});
// await MigrationService.insertError({
// db,
// data: constructMigrationError({
// migrationJobId: migrationJob.id,
// internalCustomerId: customer.internal_id,
// data: error.data || error,
// code: error.code || "unknown",
// message: error.message || "unknown",
// }),
// });
return false;
}
};
// let curCusProduct = cusProducts.find(
// (cp: FullCusProduct) => cp.product.internal_id == fromProduct.internal_id,
// );
// if (!curCusProduct) {
// logger.error(
// `Customer ${customer.id} does not have a ${fromProduct.internal_id} cus product, skipping migration`,
// );
// return false;
// }

View File

@@ -7,14 +7,9 @@ import {
MigrationJob,
MigrationJobStep,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { MigrationService } from "../MigrationService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { migrateCustomer } from "./migrateCustomer.js";
import { sendMigrationEmail } from "./sendMigrationEmail.js";
import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js";
@@ -53,8 +48,6 @@ export const migrateCustomers = async ({
orgId,
});
org.config.bill_upgrade_immediately = false;
// Create stripe prices if they don't exist
let stripeCli = createStripeCli({ org, env });
let batchCreate = [];
@@ -175,9 +168,9 @@ export const migrateCustomers = async ({
},
});
await sendMigrationEmail({
db,
migrationJobId: migrationJob.id,
org,
});
// await sendMigrationEmail({
// db,
// migrationJobId: migrationJob.id,
// org,
// });
};

View File

@@ -0,0 +1,71 @@
import { getStripeCusData } from "@/internal/customers/attach/attachUtils/attachParams/attachParamsUtils/getStripeCusData.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
APIVersion,
FullCusProduct,
FullCustomer,
FullProduct,
} from "@autumn/shared";
import Stripe from "stripe";
export const migrationToAttachParams = async ({
req,
stripeCli,
customer,
cusProduct,
newProduct,
}: {
req: ExtendedRequest;
stripeCli: Stripe;
customer: FullCustomer;
cusProduct: FullCusProduct;
newProduct: FullProduct;
}): Promise<AttachParams> => {
const { org } = req;
const apiVersion = org.config.api_version || APIVersion.v1;
const internalEntityId = cusProduct.internal_entity_id || undefined;
// const entityId = customer.entities.find(
// (e) => e.internal_id == internalEntityId,
// )?.id;
const { stripeCus, paymentMethod, now } = await getStripeCusData({
stripeCli,
stripeId: customer.processor?.id,
});
const attachParams: AttachParams = {
stripeCli,
stripeCus,
now,
paymentMethod,
customer,
products: [newProduct],
optionsList: cusProduct.options,
prices: newProduct.prices,
entitlements: newProduct.entitlements,
freeTrial: newProduct.free_trial || null,
replaceables: [],
req,
org: req.org,
entities: customer.entities,
features: req.features,
internalEntityId,
cusProducts: customer.customer_products,
// Others
apiVersion,
// successUrl: attachBody.success_url,
// invoiceOnly: attachBody.invoice_only,
// billingAnchor: attachBody.billing_cycle_anchor,
// metadata: attachBody.metadata,
// disableFreeTrial: attachBody.free_trial === false || false,
// checkoutSessionParams: attachBody.checkout_session_params,
// isCustom: attachBody.is_custom,
};
return attachParams;
};

View File

@@ -0,0 +1,84 @@
import { handleAddProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/handleAddProduct.js";
import { handleUpgradeDiffInterval } from "@/internal/customers/attach/attachFunctions/upgradeDiffIntFlow/handleUpgradeDiffInt.js";
import { handleUpgradeSameInterval } from "@/internal/customers/attach/attachFunctions/upgradeSameIntFlow/handleUpgradeSameInt.js";
import { intervalsAreSame } from "@/internal/customers/attach/attachUtils/getAttachConfig.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
AttachBranch,
AttachConfig,
AttachFunction,
ProrationBehavior,
} from "@autumn/shared";
const getAttachFunction = async ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
if (isFreeProduct(attachParams.prices)) {
return AttachFunction.AddProduct;
}
const sameIntervals = intervalsAreSame({ attachParams });
if (sameIntervals) {
return AttachFunction.UpgradeSameInterval;
}
return AttachFunction.UpgradeDiffInterval;
};
export const runMigrationAttach = async ({
req,
attachParams,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
}) => {
const { logtail: logger } = req;
const sameIntervals = intervalsAreSame({ attachParams });
const branch = AttachBranch.NewVersion;
// Set config
let config: AttachConfig = {
onlyCheckout: false,
carryUsage: true,
branch,
proration: ProrationBehavior.None,
disableTrial: true,
invoiceOnly: false,
disableMerge: false,
sameIntervals,
carryTrial: true,
};
let attachFunction = await getAttachFunction({ attachParams });
let customer = attachParams.customer;
logger.info(`--------------------------------`);
logger.info(
`Running migration for ${customer.id}, function: ${attachFunction}`,
);
if (attachFunction == AttachFunction.AddProduct) {
return await handleAddProduct({
req,
attachParams,
config,
});
} else if (attachFunction == AttachFunction.UpgradeSameInterval) {
return await handleUpgradeSameInterval({
req,
attachParams,
config,
});
} else if (attachFunction == AttachFunction.UpgradeDiffInterval) {
return await handleUpgradeDiffInterval({
req,
attachParams,
config,
});
}
};

View File

@@ -1,3 +1,9 @@
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { numberWithCommas } from "tests/utils/general/numberUtils.js";
import { getFeatureName } from "@/internal/features/utils/displayUtils.js";
import {
ProductV2,
Feature,
@@ -7,15 +13,9 @@ import {
ErrCode,
Infinite,
FullCusProduct,
CusProductStatus,
} from "@autumn/shared";
import { features } from "process";
import { isPriceItem } from "../product-items/productItemUtils/getItemType.js";
import { isFeaturePriceItem } from "../product-items/productItemUtils/getItemType.js";
import RecaseError from "@/utils/errorUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { numberWithCommas } from "tests/utils/general/numberUtils.js";
import { getFeatureName } from "@/internal/features/utils/displayUtils.js";
export const sortProductItems = (items: ProductItem[], features: Feature[]) => {
items.sort((a, b) => {
@@ -131,7 +131,7 @@ export const getPricecnPrice = ({
if (isPriceItem(priceItem)) {
return {
primaryText: getPriceText({ item: priceItem, org }),
secondaryText: `per ${priceItem.interval}`,
secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ",
};
} else {
let feature = features.find((f) => f.id == priceItem.feature_id);

View File

@@ -80,23 +80,24 @@ export const priceToInvoiceAmount = ({
// 1. If fixed price, just return amount
let amount = 0;
if (isFixedPrice({ price })) {
amount = (price.config as FixedPriceConfig).amount;
}
const config = price.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (!nullish(quantity) && !nullish(overage)) {
throw new Error(
`getAmountForPrice: quantity or overage is required, autumn price: ${price.id}`,
);
}
if (billingType == BillingType.UsageInAdvance) {
amount = getAmountForQuantity({ price, quantity: quantity! });
} else {
amount = getAmountForQuantity({ price, quantity: overage! });
const config = price.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (!nullish(quantity) && !nullish(overage)) {
throw new Error(
`getAmountForPrice: quantity or overage is required, autumn price: ${price.id}`,
);
}
if (billingType == BillingType.UsageInAdvance) {
amount = getAmountForQuantity({ price, quantity: quantity! });
} else {
amount = getAmountForQuantity({ price, quantity: overage! });
}
}
if (proration) {

View File

@@ -1,4 +0,0 @@
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { Router } from "express";
export const testRouter = Router();

View File

@@ -36,6 +36,17 @@ const initWorker = ({
let worker = new Worker(
"autumn",
async (job: Job) => {
try {
logtail.use((log: any) => {
return {
...log,
task: job.name,
data: job.data,
workerId: id,
};
});
} catch (error) {}
if (job.name == JobName.GenerateFeatureDisplay) {
await runSaveFeatureDisplayTask({
db,

View File

@@ -149,7 +149,7 @@ export const createUpgradeProrationInvoice = async ({
stripeSubId: sub.id,
});
const { invoice: paidInvoice } = await payForInvoice({
const { invoice: paidInvoice, error } = await payForInvoice({
stripeCli,
paymentMethod,
invoiceId: finalInvoice.id,

View File

@@ -279,10 +279,12 @@ export const runUpdateUsageTask = async ({
payload,
logger,
db,
throwError = false,
}: {
payload: any;
logger: any;
db: DrizzleCli;
throwError?: boolean;
}) => {
try {
// 1. Update customer balance
@@ -321,19 +323,11 @@ export const runUpdateUsageTask = async ({
}
console.log(" ✅ Customer balance updated");
} catch (error) {
if (logger) {
logger.use((log: any) => {
return {
...log,
task: JobName.UpdateUsage,
data: payload,
};
});
logger.error(`ERROR UPDATING USAGE`);
logger.error(error);
logger.error(`ERROR UPDATING USAGE`);
logger.error(error);
} else {
console.log(error);
if (throwError) {
throw error;
}
}
};

View File

@@ -4,15 +4,17 @@ export const formatAmount = ({
org,
amount,
maxFractionDigits = 2,
minFractionDigits = 0,
}: {
org?: Organization;
amount: number;
maxFractionDigits?: number;
minFractionDigits?: number;
}) => {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: org?.default_currency || "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 2,
minimumFractionDigits: minFractionDigits || 0,
maximumFractionDigits: maxFractionDigits || 2,
}).format(amount);
};

View File

@@ -10,16 +10,19 @@ import {
export const constructFeatureItem = ({
featureId,
includedUsage = 150,
interval = ProductItemInterval.Month,
entityFeatureId,
}: {
featureId: string;
includedUsage?: number;
interval?: ProductItemInterval;
entityFeatureId?: string;
}) => {
let item: ProductItem = {
feature_id: featureId,
included_usage: includedUsage,
entity_feature_id: entityFeatureId,
interval: interval,
};
return item;
@@ -29,11 +32,13 @@ export const constructPrepaidItem = ({
featureId,
price,
billingUnits = 100,
includedUsage = 0,
isOneOff = false,
}: {
featureId: string;
price: number;
billingUnits?: number;
includedUsage?: number;
isOneOff?: boolean;
}) => {
let item: ProductItem = {
@@ -44,6 +49,7 @@ export const constructPrepaidItem = ({
billing_units: billingUnits || 100,
interval: isOneOff ? null : ProductItemInterval.Month,
included_usage: includedUsage,
};
return item;
@@ -54,11 +60,18 @@ export const constructArrearItem = ({
includedUsage = 10000,
price = 0.1,
billingUnits = 1000,
config = {
// on_increase: OnIncrease.BillImmediately,
// on_decrease: OnDecrease.None,
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
}: {
featureId: string;
includedUsage?: number;
price?: number;
billingUnits?: number;
config?: ProductItemConfig;
}) => {
let item: ProductItem = {
feature_id: featureId,
@@ -68,6 +81,7 @@ export const constructArrearItem = ({
billing_units: billingUnits,
interval: ProductItemInterval.Month,
reset_usage_when_enabled: true,
config,
};
return item;

View File

@@ -1,76 +0,0 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import { findContUsePrice } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import { AppEnv, Organization } from "@autumn/shared";
import { expect } from "chai";
import Stripe from "stripe";
import { TestFeature } from "tests/setup/v2Features.js";
export const expectSubQuantityCorrect = async ({
stripeCli,
productId,
usage,
itemQuantity,
db,
org,
env,
customerId,
numReplaceables = 0,
}: {
stripeCli: Stripe;
productId: string;
usage: number;
itemQuantity: number;
db: DrizzleCli;
org: Organization;
env: AppEnv;
customerId: string;
numReplaceables?: number;
}) => {
const fullCus = await CusService.getFull({
db,
orgId: org.id,
env,
idOrInternalId: customerId,
});
let cusProduct = fullCus.customer_products.find(
(cp) => cp.product_id === productId,
);
let stripeSubs = await getStripeSubs({
stripeCli,
subIds: cusProduct?.subscription_ids,
});
let subItems = stripeSubs.flatMap((sub) => sub.items.data);
let prices = cusProductToPrices({ cusProduct: cusProduct! });
let contPrice = findContUsePrice({ prices });
let subItem = findStripeItemForPrice({
price: contPrice!,
stripeItems: subItems,
});
expect(subItem).to.exist;
expect(subItem!.quantity).to.equal(itemQuantity);
// Check num replaceables correct
let cusEnts = cusProduct?.customer_entitlements;
let cusEnt = cusEnts?.find((ent) => ent.feature_id === TestFeature.Users);
expect(cusEnt).to.exist;
expect(cusEnt?.replaceables.length).to.equal(numReplaceables);
let expectedBalance = cusEnt!.entitlement.allowance! - usage;
expect(cusEnt!.balance).to.equal(expectedBalance);
return {
stripeSubs,
cusProduct,
};
};

View File

@@ -0,0 +1,183 @@
import { expect } from "chai";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import { AppEnv, Organization, ProductV2 } from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addWeeks } from "date-fns";
import { defaultApiVersion } from "tests/constants.js";
import { runMigrationTest } from "./runMigrationTest.js";
let messagesItem = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 500,
});
let wordsItem = constructFeatureItem({
featureId: TestFeature.Words,
includedUsage: 100,
});
export let free = constructProduct({
items: [messagesItem, wordsItem],
type: "free",
isDefault: false,
});
const testCase = "migrations1";
describe(`${chalk.yellowBright(`${testCase}: Testing migration for free product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [free],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [free],
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach free product", async function () {
await runAttachTest({
autumn,
customerId,
product: free,
stripeCli,
db,
org,
env,
});
});
let newFree: ProductV2;
let increaseMessagesBy = 100;
let reduceWordsBy = 50;
it("should update product to new version", async function () {
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,
});
});
it("should attach track usage and get correct balance", async function () {
let wordsUsage = 25;
let 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 advanceTestClock({
stripeCli,
testClockId,
advanceTo: addWeeks(Date.now(), 1).getTime(),
});
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 timeout(4000);
// 1. Get features
customer = await autumn.customers.get(customerId);
await runMigrationTest({
autumn,
stripeCli,
customerId,
fromProduct: free,
toProduct: newFree,
db,
org,
env,
usage: [
{
featureId: TestFeature.Words,
value: wordsUsage,
},
{
featureId: TestFeature.Messages,
value: messagesUsage,
},
],
});
});
});

View File

@@ -0,0 +1,163 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
AppEnv,
BillingInterval,
Organization,
ProductItemInterval,
ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addWeeks } from "date-fns";
import { defaultApiVersion } from "tests/constants.js";
import { runMigrationTest } from "./runMigrationTest.js";
let wordsItem = constructArrearItem({
featureId: TestFeature.Words,
});
export let pro = constructProduct({
items: [wordsItem],
type: "pro",
isDefault: false,
});
const testCase = "migrations2";
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro usage product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [pro],
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach free product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
let newPro: ProductV2;
let increaseWordsBy = 1500;
it("should update product to new version", async function () {
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;
await autumn.products.update(pro.id, {
items: newItems,
});
});
it("should attach track usage and get correct balance", async function () {
let wordsUsage = 120000;
await autumn.track({
customer_id: customerId,
value: wordsUsage,
feature_id: TestFeature.Words,
});
await advanceTestClock({
stripeCli,
testClockId,
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,
customerId,
fromProduct: pro,
toProduct: newPro,
db,
org,
env,
usage: [
{
featureId: TestFeature.Words,
value: wordsUsage,
},
],
});
});
});

View File

@@ -0,0 +1,159 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
AppEnv,
BillingInterval,
Organization,
ProductItemInterval,
ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { defaultApiVersion } from "tests/constants.js";
import { runMigrationTest } from "./runMigrationTest.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays } from "date-fns";
let wordsItem = constructArrearItem({
featureId: TestFeature.Words,
});
export let pro = constructProduct({
items: [wordsItem],
type: "pro",
isDefault: false,
trial: true,
});
const testCase = "migrations3";
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with trial`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [pro],
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach free product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
let newPro: ProductV2;
let increaseWordsBy = 1500;
it("should update product to new version", async function () {
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,
});
});
it("should attach track usage and get correct balance", async function () {
let wordsUsage = 120000;
await autumn.track({
customer_id: customerId,
value: wordsUsage,
feature_id: TestFeature.Words,
});
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addDays(Date.now(), 4).getTime(),
});
await timeout(5000);
await runMigrationTest({
autumn,
stripeCli,
customerId,
fromProduct: pro,
toProduct: newPro,
db,
org,
env,
usage: [
{
featureId: TestFeature.Words,
value: wordsUsage,
},
],
});
});
});

View File

@@ -0,0 +1,146 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
AppEnv,
BillingInterval,
Organization,
ProductItemInterval,
ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { defaultApiVersion } from "tests/constants.js";
import { runMigrationTest } from "./runMigrationTest.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addDays } from "date-fns";
let wordsItem = constructArrearItem({
featureId: TestFeature.Words,
});
export let pro = constructProduct({
items: [wordsItem],
type: "pro",
isDefault: false,
});
let newWordsItem = constructArrearItem({
featureId: TestFeature.Words,
includedUsage: 120100,
});
let proWithTrial = constructProduct({
items: [newWordsItem],
type: "pro",
isDefault: false,
trial: true,
});
const testCase = "migrations4";
describe(`${chalk.yellowBright(`${testCase}: Testing migration for pro with trial`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: defaultApiVersion });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [pro],
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach free product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
it("should update product to new version", async function () {
await autumn.products.update(pro.id, {
items: proWithTrial.items,
});
});
it("should attach track usage and get correct balance", async function () {
let wordsUsage = 120000;
await autumn.track({
customer_id: customerId,
value: wordsUsage,
feature_id: TestFeature.Words,
});
await timeout(4000);
// await advanceTestClock({
// stripeCli,
// testClockId,
// advanceTo: addDays(Date.now(), 4).getTime(),
// });
await runMigrationTest({
autumn,
stripeCli,
customerId,
fromProduct: pro,
toProduct: proWithTrial,
db,
org,
env,
usage: [
{
featureId: TestFeature.Words,
value: wordsUsage,
},
],
});
});
});

View File

@@ -0,0 +1,115 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { ProductV2, Organization } from "@autumn/shared";
import { AppEnv } from "autumn-js";
import {
expectSubItemsCorrect,
getSubsFromCusId,
} from "tests/utils/expectUtils/expectSubUtils.js";
import Stripe from "stripe";
import { expect } from "chai";
import { expectFeaturesCorrect } from "tests/utils/expectUtils/expectFeaturesCorrect.js";
import { expectResetAtCorrect } from "tests/utils/expectUtils/expectAttach/expectResetAtCorrect.js";
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
import { expectTrialEndsAtCorrect } from "tests/utils/expectUtils/expectAttach/expectTrialEndsAt.js";
import { timeout } from "@/utils/genUtils.js";
export const expectSubsSame = ({
subsBefore,
subsAfter,
}: {
subsBefore: Stripe.Subscription[];
subsAfter: Stripe.Subscription[];
}) => {
let invoicesBefore = subsBefore.map((sub) => sub.latest_invoice);
let invoicesAfter = subsAfter.map((sub) => sub.latest_invoice);
let subIdsBefore = subsBefore.map((sub) => sub.id);
let subIdsAfter = subsAfter.map((sub) => sub.id);
let periodEndsBefore = subsBefore.map((sub) => sub.current_period_end);
let periodEndsAfter = subsAfter.map((sub) => sub.current_period_end);
expect(invoicesAfter).to.deep.equal(invoicesBefore);
expect(subIdsAfter).to.deep.equal(subIdsBefore);
expect(periodEndsAfter).to.deep.equal(periodEndsBefore);
};
export const runMigrationTest = async ({
autumn,
stripeCli,
customerId,
fromProduct,
toProduct,
db,
org,
env,
usage,
numInvoices = 1,
}: {
autumn: AutumnInt;
stripeCli: Stripe;
customerId: string;
fromProduct: ProductV2;
toProduct: ProductV2;
db: DrizzleCli;
org: Organization;
env: AppEnv;
usage?: {
featureId: string;
value: number;
}[];
numInvoices?: number;
}) => {
const { subs: subsBefore } = await getSubsFromCusId({
stripeCli,
customerId,
productId: fromProduct.id,
db,
org,
env,
});
const cusBefore = await autumn.customers.get(customerId);
await autumn.migrate({
from_product_id: fromProduct.id,
to_product_id: toProduct.id,
from_version: fromProduct.version,
to_version: toProduct.version,
});
await timeout(5000);
const { subs: subsAfter } = await getSubsFromCusId({
stripeCli,
customerId,
productId: toProduct.id,
db,
org,
env,
});
expectSubsSame({ subsBefore, subsAfter });
const cusAfter = await autumn.customers.get(customerId);
expectFeaturesCorrect({
customer: cusAfter,
product: toProduct,
usage,
});
expectResetAtCorrect({ cusBefore, cusAfter });
expectTrialEndsAtCorrect({ cusBefore, cusAfter });
await expectSubItemsCorrect({
stripeCli,
customerId,
product: toProduct,
db,
org,
env,
});
if (!isFreeProductV2({ product: toProduct })) {
expect(cusAfter.invoices.length).to.equal(numInvoices);
}
};

View File

@@ -17,17 +17,17 @@ import { addPrefixToProducts } from "../utils.js";
// UNCOMMENT FROM HERE
let pro = constructProduct({
id: "attach1_pro",
id: "pro",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "pro",
});
let premium = constructProduct({
id: "attach1_premium",
id: "premium",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "premium",
});
let growth = constructProduct({
id: "attach1_growth",
id: "growth",
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "growth",
});
@@ -60,6 +60,9 @@ describe(`${chalk.yellowBright("attach/upgrade1: Testing usage upgrades")}`, ()
await createProducts({
autumn: autumnJs,
products: [pro, premium, growth],
db,
orgId: org.id,
env,
customerId,
});

View File

@@ -3,7 +3,6 @@ import {
AppEnv,
AttachBranch,
BillingInterval,
Customer,
FeatureOptions,
Organization,
ProductItem,
@@ -29,6 +28,7 @@ import {
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { Decimal } from "decimal.js";
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
export const runAttachTest = async ({
autumn,
@@ -102,11 +102,14 @@ export const runAttachTest = async ({
).filter(notNullish);
const multiInterval = intervals.length > 1;
expectInvoicesCorrect({
customer,
first: multiInterval ? undefined : { productId: product.id, total },
second: multiInterval ? { productId: product.id, total } : undefined,
});
const freeProduct = isFreeProductV2({ product });
if (!freeProduct) {
expectInvoicesCorrect({
customer,
first: multiInterval ? undefined : { productId: product.id, total },
second: multiInterval ? { productId: product.id, total } : undefined,
});
}
if (!skipFeatureCheck) {
expectFeaturesCorrect({
@@ -118,7 +121,7 @@ export const runAttachTest = async ({
}
const branch = preview.branch;
if (branch == AttachBranch.OneOff) {
if (branch == AttachBranch.OneOff || freeProduct) {
return;
}
await expectSubItemsCorrect({

View File

@@ -0,0 +1,3 @@
import { APIVersion } from "@autumn/shared";
export const defaultApiVersion = APIVersion.v1_2;

View File

@@ -17,7 +17,7 @@ import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { expect } from "chai";
import { expectSubQuantityCorrect } from "../../attach/entities/expectEntity.js";
import { expectSubQuantityCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
import { addWeeks } from "date-fns";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";

View File

@@ -0,0 +1,196 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
OnDecrease,
OnIncrease,
Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../../attach/utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import {
calcProrationAndExpectInvoice,
expectSubQuantityCorrect,
} from "tests/utils/expectUtils/expectContUseUtils.js";
import { addWeeks } from "date-fns";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { timeout } from "@/utils/genUtils.js";
let userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 50,
includedUsage: 1,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
});
export let pro = constructProduct({
items: [userItem],
type: "pro",
});
const testCase = "entity2";
describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing entities, prorate now`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
let usage = 0;
let firstEntities = [
{
id: "1",
name: "test",
featureId: TestFeature.Users,
},
];
it("should create entity, then attach pro", async function () {
await autumn.entities.create(customerId, firstEntities);
usage += 1;
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
usage: [
{
featureId: TestFeature.Users,
value: usage,
},
],
});
});
const newEntities = [
{
id: "2",
name: "test",
featureId: TestFeature.Users,
},
{
id: "3",
name: "test2",
featureId: TestFeature.Users,
},
];
it("should create 2 entities and have correct invoice", async function () {
curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addWeeks(new Date(), 2).getTime(),
waitForSeconds: 10,
});
await autumn.entities.create(customerId, newEntities);
usage += newEntities.length;
const { stripeSubs } = await expectSubQuantityCorrect({
stripeCli,
productId: pro.id,
db,
org,
env,
customerId,
usage,
itemQuantity: usage,
});
await calcProrationAndExpectInvoice({
autumn,
stripeSubs,
customerId,
quantity: newEntities.length,
unitPrice: userItem.price!,
curUnix,
numInvoices: 2,
});
});
it("should delete 1 entity and have correct invoice amount", async function () {
curUnix = await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addWeeks(curUnix, 1).getTime(),
waitForSeconds: 10,
});
await timeout(5000);
await autumn.entities.delete(customerId, newEntities[0].id);
usage -= 1;
const { stripeSubs } = await expectSubQuantityCorrect({
stripeCli,
productId: pro.id,
db,
org,
env,
customerId,
usage,
});
await calcProrationAndExpectInvoice({
autumn,
stripeSubs,
customerId,
quantity: -1,
unitPrice: userItem.price!,
curUnix,
numInvoices: 3,
});
});
});

View File

@@ -1,4 +1,9 @@
// Handling per entity features!
import { TestFeature } from "tests/setup/v2Features.js";
import { expect } from "chai";
import { timeout } from "@/utils/genUtils.js";
import { useEntityBalanceAndExpect } from "tests/utils/expectUtils/expectContUse/expectEntityUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
@@ -20,13 +25,6 @@ import {
constructArrearProratedItem,
constructFeatureItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { expect } from "chai";
import { expectSubQuantityCorrect } from "../../attach/entities/expectEntity.js";
import { addHours, addMonths, addWeeks } from "date-fns";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getBasePrice } from "tests/utils/testProductUtils/testProductUtils.js";
let userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
@@ -141,63 +139,101 @@ describe(`${chalk.yellowBright(`attach/entities/${testCase}: Testing per entity
await autumn.entities.create(customerId, newEntities);
usage += newEntities.length;
return;
let customer = await autumn.customers.get(customerId, {
expand: [CusExpand.Entities],
});
let balance = await autumn.check({
let res = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
expect(balance.quantity).to.equal(
expect(res.balance).to.equal(
(perEntityItem.included_usage as number) * usage,
);
for (const entity of customer.entities) {
let balance = await autumn.check({
let entRes = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: entity.id,
});
expect(balance.quantity).to.equal(perEntityItem.included_usage);
expect(entRes.balance).to.equal(perEntityItem.included_usage);
}
});
return;
// 1. Use from main balance...
it("should use from top level balance", async function () {
let deduction = 600;
let perEntityIncluded = perEntityItem.included_usage as number;
it("should advance clock to next cycle and have correct invoice", async function () {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
).getTime(),
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deduction,
});
await timeout(5000);
let { balance } = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
usage -= 2; // 2 entities deleted
expect(balance).to.equal(perEntityIncluded * usage - deduction);
});
const customer = await autumn.customers.get(customerId);
const invoices = customer.invoices;
let basePrice = getBasePrice({ product: pro });
expect(invoices.length).to.equal(2);
expect(invoices[0].total).to.equal(basePrice); // 0 entities
await expectSubQuantityCorrect({
stripeCli,
productId: pro.id,
db,
org,
env,
it("should use from entity balance", async function () {
await useEntityBalanceAndExpect({
autumn,
customerId,
usage,
itemQuantity: usage,
numReplaceables: 0,
featureId: TestFeature.Messages,
entityId: "2",
});
await useEntityBalanceAndExpect({
autumn,
customerId,
featureId: TestFeature.Messages,
entityId: "3",
});
});
// Delete one entity and create a new one and master balance should be same
let deletedEntityId = "2";
let newEntity = {
id: "4",
name: "test",
featureId: TestFeature.Users,
};
it("should delete one entity and create a new one", async function () {
let { balance: masterBalanceBefore } = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
let { balance: entityBalanceBefore } = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: deletedEntityId,
});
await autumn.entities.delete(customerId, deletedEntityId);
await autumn.entities.create(customerId, [newEntity]);
let { balance: masterBalanceAfter } = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
expect(masterBalanceAfter).to.equal(masterBalanceBefore);
let { balance: entityBalanceAfter } = await autumn.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
entity_id: newEntity.id,
});
expect(entityBalanceAfter).to.equal(entityBalanceBefore);
});
});

View File

@@ -1,3 +1,185 @@
// test payment failures
// test update product / delete entity mix -- should have correct sub item quantity...
import { TestFeature } from "tests/setup/v2Features.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
ErrCode,
OnDecrease,
OnIncrease,
Organization,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../../attach/utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { expectSubQuantityCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
let userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: 50,
includedUsage: 1,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
export let pro = constructProduct({
items: [userItem],
type: "pro",
});
const testCase = "entity5";
describe(`${chalk.yellowBright(`contUse/${testCase}: Testing create entity payment fail`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
customerId,
db,
orgId: org.id,
env,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
let usage = 0;
let firstEntities = [
{
id: "1",
name: "test",
featureId: TestFeature.Users,
},
];
it("should create one entity, then attach pro", async function () {
await autumn.entities.create(customerId, firstEntities);
usage += firstEntities.length;
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
usage: [
{
featureId: TestFeature.Users,
value: usage,
},
],
});
});
it("should attach failed payment method", async function () {
let fullCus = await CusService.getFull({
db,
idOrInternalId: customerId,
orgId: org.id,
env,
});
await attachFailedPaymentMethod({
stripeCli,
customer: fullCus,
});
});
it("should try to create entities and fail", async function () {
await expectAutumnError({
errMessage: "Your card was declined.",
func: async () => {
await autumn.entities.create(customerId, [
{
id: "2",
name: "test",
featureId: TestFeature.Users,
},
{
id: "3",
name: "test",
featureId: TestFeature.Users,
},
]);
},
});
await expectSubQuantityCorrect({
stripeCli,
productId: pro.id,
db,
org,
env,
customerId,
usage,
numReplaceables: 0,
});
});
it("should track usage for users and fail", async function () {
await expectAutumnError({
errMessage: "Your card was declined.",
func: async () => {
return await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
},
});
await expectSubQuantityCorrect({
stripeCli,
productId: pro.id,
db,
org,
env,
customerId,
usage,
numReplaceables: 0,
});
});
});

View File

@@ -21,8 +21,8 @@ import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addPrefixToProducts } from "tests/attach/utils.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js";
import { expectUpcomingItemsCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
import { expectSubQuantityCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
let userItem = constructArrearProratedItem({
featureId: TestFeature.Users,

View File

@@ -17,7 +17,7 @@ import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.j
import { TestFeature } from "tests/setup/v2Features.js";
import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js";
import { expectSubQuantityCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
import { attachNewContUseAndExpectCorrect } from "tests/utils/expectUtils/expectContUse/expectUpdateContUse.js";
let userItem = constructArrearProratedItem({

View File

@@ -17,7 +17,7 @@ import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.j
import { TestFeature } from "tests/setup/v2Features.js";
import { addPrefixToProducts, replaceItems } from "tests/attach/utils.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectSubQuantityCorrect } from "tests/attach/entities/expectEntity.js";
import { expectSubQuantityCorrect } from "tests/utils/expectUtils/expectContUseUtils.js";
import { attachNewContUseAndExpectCorrect } from "tests/utils/expectUtils/expectContUse/expectUpdateContUse.js";
import { expect } from "chai";
import { advanceTestClock } from "tests/utils/stripeUtils.js";

View File

@@ -0,0 +1,27 @@
import { Customer } from "autumn-js";
import { expect } from "chai";
export const expectResetAtCorrect = ({
cusBefore,
cusAfter,
}: {
cusBefore: Customer;
cusAfter: Customer;
}) => {
const featuresBefore = cusBefore.features;
const featuresAfter = cusAfter.features;
for (const featureId in featuresBefore) {
const featureBefore = featuresBefore[featureId];
if (!featureBefore.next_reset_at) {
continue;
}
const featureAfter = featuresAfter[featureId];
expect(featureAfter.next_reset_at).to.be.approximately(
featureBefore.next_reset_at,
10000,
`reset for ${featureId} should be within 10 seconds`,
);
}
};

View File

@@ -0,0 +1,26 @@
import { Customer } from "autumn-js";
import { expect } from "chai";
export const expectTrialEndsAtCorrect = ({
cusBefore,
cusAfter,
}: {
cusBefore: Customer;
cusAfter: Customer;
}) => {
let productsBefore = cusBefore.products;
let productsAfter = cusAfter.products;
for (const productBefore of productsBefore) {
// @ts-ignore
let trialEndsAtBefore = productBefore.trial_ends_at;
if (!trialEndsAtBefore) {
continue;
}
const productAfter = productsAfter.find((p) => p.id === productBefore.id);
// @ts-ignore
expect(productAfter?.trial_ends_at).to.equal(trialEndsAtBefore);
}
};

View File

@@ -0,0 +1,49 @@
import { Decimal } from "decimal.js";
import { timeout } from "@/utils/genUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { expect } from "chai";
export const useEntityBalanceAndExpect = async ({
autumn,
customerId,
featureId,
entityId,
}: {
autumn: AutumnInt;
customerId: string;
featureId: string;
entityId: string;
}) => {
let deduction = new Decimal(Math.random() * 400)
.toDecimalPlaces(5)
.toNumber();
let balanceBefore = await autumn.check({
customer_id: customerId,
feature_id: featureId,
entity_id: entityId,
});
await autumn.track({
customer_id: customerId,
feature_id: featureId,
value: deduction,
entity_id: entityId,
});
await timeout(3000);
let balanceAfter = await autumn.check({
customer_id: customerId,
feature_id: featureId,
entity_id: entityId,
});
let expectedBalance = new Decimal(balanceBefore.balance!)
.sub(deduction)
.toNumber();
expect(balanceAfter.balance).to.equal(
expectedBalance,
"Entity balance should be correct",
);
};

View File

@@ -9,6 +9,7 @@ import { AppEnv, FullCustomer, Organization } from "@autumn/shared";
import { expect } from "chai";
import { TestFeature } from "tests/setup/v2Features.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
export const expectSubQuantityCorrect = async ({
stripeCli,
@@ -18,6 +19,7 @@ export const expectSubQuantityCorrect = async ({
org,
env,
customerId,
itemQuantity,
numReplaceables = 0,
}: {
stripeCli: Stripe;
@@ -27,6 +29,7 @@ export const expectSubQuantityCorrect = async ({
org: Organization;
env: AppEnv;
customerId: string;
itemQuantity?: number;
numReplaceables?: number;
}) => {
const fullCus = await CusService.getFull({
@@ -56,7 +59,7 @@ export const expectSubQuantityCorrect = async ({
});
expect(subItem).to.exist;
expect(subItem!.quantity).to.equal(usage);
expect(subItem!.quantity).to.equal(itemQuantity || usage);
// Check num replaceables correct
let cusEnts = cusProduct?.customer_entitlements;
@@ -67,6 +70,12 @@ export const expectSubQuantityCorrect = async ({
let expectedBalance = cusEnt!.entitlement.allowance! - usage;
expect(cusEnt!.balance).to.equal(expectedBalance);
return {
fullCus,
cusProduct,
stripeSubs,
};
};
export const expectUpcomingItemsCorrect = async ({
@@ -112,3 +121,45 @@ export const expectUpcomingItemsCorrect = async ({
expect(lines[0].amount).to.equal(Math.round(proratedAmount * 100));
};
export const calcProrationAndExpectInvoice = async ({
autumn,
stripeSubs,
customerId,
quantity,
unitPrice,
curUnix,
numInvoices,
}: {
autumn: AutumnInt;
stripeSubs: Stripe.Subscription[];
customerId: string;
quantity: number;
unitPrice: number;
curUnix: number;
numInvoices: number;
}) => {
let customer = await autumn.customers.get(customerId);
let invoices = customer.invoices;
let sub = stripeSubs[0];
let amount = quantity * unitPrice;
let proratedAmount = calculateProrationAmount({
amount,
periodStart: sub.current_period_start * 1000,
periodEnd: sub.current_period_end * 1000,
now: curUnix,
allowNegative: true,
});
proratedAmount = Number(proratedAmount.toFixed(2));
expect(invoices.length).to.equal(
numInvoices,
`Should have ${numInvoices} invoices`,
);
expect(invoices[0].total).to.equal(
proratedAmount,
"Latest invoice should be equals to calculated prorated amount",
);
};

View File

@@ -4,24 +4,35 @@ import AutumnError from "@/external/autumn/autumnCli.js";
export const expectAutumnError = async ({
errCode,
errMessage,
func,
}: {
errCode: string;
errCode?: string;
errMessage?: string;
func: () => Promise<any>;
}) => {
try {
await func();
let result = await func();
assert.fail(
`Expected to receive autumn error ${errCode}, but received none`,
);
} catch (error: any) {
// 1. Expect error to be instance of AutumnError
expect(error, "Error should be instance of AutumnError").to.be.instanceOf(
AutumnError,
);
// 2. Expect error code to be the same as the one passed in
expect(error.code, `Error code should be ${errCode}`).to.equal(errCode);
if (errMessage) {
expect(error.message, `Error message should be ${errMessage}`).to.equal(
errMessage,
);
}
if (errCode) {
// 2. Expect error code to be the same as the one passed in
expect(error.code, `Error code should be ${errCode}`).to.equal(errCode);
}
}
};

View File

@@ -15,4 +15,5 @@ export interface AttachConfig {
invoiceOnly: boolean;
disableMerge: boolean;
sameIntervals: boolean;
carryTrial: boolean;
}

View File

@@ -1,6 +1,9 @@
import { FullCusProduct } from "../cusProductModels/cusProductModels.js";
import { FreeTrial } from "../productModels/freeTrialModels/freeTrialModels.js";
import { FullProduct } from "../productModels/productModels.js";
import { UsageModel } from "../productV2Models/productItemModels/productItemModels.js";
import { AttachBranch } from "./attachEnums/AttachBranch.js";
import { AttachFunction } from "./attachEnums/AttachFunction.js";
export interface PreviewLineItem {
amount: number | undefined;
@@ -11,16 +14,18 @@ export interface PreviewLineItem {
}
export interface AttachPreview {
func: AttachFunction;
branch: AttachBranch;
options: any;
new_items: any;
due_today: {
line_items: PreviewLineItem[];
total: string;
total: number;
};
due_next_cycle: {
line_items: PreviewLineItem[];
due_at: number;
};
free_trial?: FreeTrial | null;
current_product?: FullProduct;
}

View File

@@ -3,9 +3,6 @@ import { Infinite } from "../productModels/productEnums.js";
import { ProductResponse } from "../productV2Models/productResponseModels.js";
export enum AttachScenario {
// AlreadyAttached = "already_attached",
// AlreadyScheduled = "already_scheduled",
Scheduled = "scheduled",
Active = "active",
New = "new",
@@ -71,7 +68,7 @@ export enum FeaturePreviewScenario {
FeatureFlag = "feature_flag",
}
export interface CheckFeatureFormattedPreview {
export interface CheckFeaturePreview {
title: string;
message: string;
@@ -80,6 +77,4 @@ export interface CheckFeatureFormattedPreview {
feature_name: string;
products: ProductResponse[];
// next_main_product: ProductResponse | null;
// next_add_on_product: ProductResponse | null;
}

View File

@@ -41,7 +41,7 @@ export default function ProductChangeDialog(params?: ProductChangeDialogProps) {
let sum = 0;
optionsInput.forEach((option) => {
if (option.price && option.quantity) {
sum += option.price * option.quantity;
sum += option.price * (option.quantity / option.billing_units);
}
});
setPrepaidTotals(sum);