feat: improving update quantity with proration configs

This commit is contained in:
John Yeo
2025-07-05 20:45:13 +01:00
parent 68cc7fd392
commit 0174c80db8
70 changed files with 2713 additions and 283 deletions

3
pnpm-lock.yaml generated
View File

@@ -280,6 +280,9 @@ importers:
shared:
dependencies:
date-fns:
specifier: ^4.1.0
version: 4.1.0
decimal.js:
specifier: ^10.5.0
version: 10.5.0

View File

@@ -13,4 +13,4 @@ MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \
'tests/advanced/check/*.ts' \
'tests/attach/others/*.ts'
$MOCHA_CMD 'tests/attach/prepaid/*.ts'

View File

@@ -110,6 +110,10 @@ const resetCustomerEntitlement = async ({
// 2. Quantity is from prices...
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
if (relatedCusPrice) {
return;
}
const entOptions = getEntOptions(
cusEnt.customer_product.options,
cusEnt.entitlement,
@@ -118,11 +122,10 @@ const resetCustomerEntitlement = async ({
const resetBalance = getResetBalance({
entitlement: cusEnt.entitlement,
options: entOptions,
relatedPrice: relatedCusPrice?.price,
relatedPrice: undefined,
// relatedPrice: relatedCusPrice,
});
// 3. Update the next_reset_at for each entitlement
// Handle if entitlement changed to unlimited...
let entitlement = cusEnt.entitlement;
if (entitlement.allowance_type === AllowanceType.Unlimited) {

View File

@@ -63,10 +63,12 @@ export const createStripeSub = async ({
: undefined;
const { items, prices, usageFeatures } = itemSet;
let subItems = items.filter(
(i: any, index: number) =>
prices[index].config!.interval !== BillingInterval.OneOff,
);
let invoiceItems = items.filter(
(i: any, index: number) =>
prices[index].config!.interval === BillingInterval.OneOff,

View File

@@ -28,6 +28,8 @@ import { DrizzleCli } from "@/db/initDrizzle.js";
import { getFullStripeInvoice } from "../../stripeInvoiceUtils.js";
import { handleUsagePrices } from "./handleUsagePrices.js";
import { handleContUsePrices } from "./handleContUsePrices.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { handlePrepaidPrices } from "./handlePrepaidPrices.js";
const handleInArrearProrated = async ({
db,
@@ -194,10 +196,7 @@ export const sendUsageAndReset = async ({
const price = cusPrice.price;
let billingType = getBillingType(price.config);
if (
billingType !== BillingType.UsageInArrear &&
billingType !== BillingType.InArrearProrated
) {
if (isFixedPrice({ price })) {
continue;
}
@@ -252,24 +251,23 @@ export const sendUsageAndReset = async ({
stripeCli,
cusEnts,
cusPrice,
// customer,
// org,
// env,
invoice,
usageSub: usageBasedSub,
logger,
});
// await handleInArrearProrated({
// db,
// cusEnts,
// cusPrice,
// customer,
// org,
// env,
// invoice,
// usageSub: usageBasedSub,
// logger,
// });
}
if (billingType == BillingType.UsageInAdvance) {
await handlePrepaidPrices({
db,
stripeCli,
cusPrice,
cusProduct: activeProduct,
usageSub: usageBasedSub,
customer,
invoice,
logger,
});
}
}
};

View File

@@ -0,0 +1,106 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getResetBalance } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
Customer,
EntInterval,
FeatureOptions,
FullCusProduct,
FullCustomerPrice,
} from "@autumn/shared";
import Stripe from "stripe";
export const handlePrepaidPrices = async ({
db,
stripeCli,
cusProduct,
cusPrice,
usageSub,
customer,
invoice,
logger,
}: {
db: DrizzleCli;
stripeCli: Stripe;
cusProduct: FullCusProduct;
cusPrice: FullCustomerPrice;
usageSub: Stripe.Subscription;
customer: Customer;
invoice: Stripe.Invoice;
logger: any;
}) => {
const isNewPeriod = invoice.period_start !== usageSub.current_period_start;
if (!isNewPeriod) {
return;
}
const cusEnt = getRelatedCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
if (!cusEnt) {
logger.error(
`Tried to handle prepaid price for ${cusPrice.id} (${cusPrice.price.id}) but no cus ent found`,
);
return;
}
const options = getEntOptions(cusProduct.options, cusEnt.entitlement);
const resetBalance = getResetBalance({
entitlement: cusEnt.entitlement,
options: notNullish(options?.upcoming_quantity)
? {
feature_id: options?.feature_id!,
quantity: options?.upcoming_quantity!,
}
: options,
relatedPrice: cusPrice.price,
});
const ent = cusEnt.entitlement;
if (notNullish(options?.upcoming_quantity)) {
const newOptions = cusProduct.options.map((o) => {
if (o.feature_id == ent.feature_id) {
return {
...o,
quantity: o.upcoming_quantity,
upcoming_quantity: undefined,
};
}
return o;
});
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: {
options: newOptions as FeatureOptions[],
},
});
}
if (ent.interval == EntInterval.Lifetime) {
return;
}
logger.info(
`🔥 Resetting balance for ${ent.feature.id}, customer: ${customer.id} (name: ${customer.name})`,
);
await CusEntService.update({
db,
id: cusEnt.id,
updates: {
balance: resetBalance,
adjustment: 0,
next_reset_at: usageSub.current_period_end * 1000,
},
});
};

View File

@@ -1,4 +1,5 @@
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getCusFeaturesResponse } from "@/internal/customers/cusUtils/cusFeatureResponseUtils/getCusFeaturesResponse.js";
import { processFullCusProducts } from "@/internal/customers/cusUtils/cusProductResponseUtils/processFullCusProducts.js";
@@ -47,7 +48,7 @@ export const getEntityResponse = async ({
idOrInternalId: customerId,
orgId: org.id,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
inStatuses: ACTIVE_STATUSES,
withEntities: true,
withSubs: true,
expand,

View File

@@ -18,6 +18,9 @@ import { getCheckPreview } from "./getCheckPreview.js";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { notNullish } from "@/utils/genUtils.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { Decimal } from "decimal.js";
export const getBooleanEntitledResult = async ({
db,
@@ -93,6 +96,7 @@ export const getOptions = ({
proration,
now,
freeTrial,
cusProduct,
}: {
prodItems: ProductItem[];
features: Feature[];
@@ -103,6 +107,7 @@ export const getOptions = ({
};
now?: number;
freeTrial?: FreeTrial | null;
cusProduct?: FullCusProduct;
}) => {
now = now || Date.now();
@@ -121,6 +126,7 @@ export const getOptions = ({
proration: finalProration,
now,
});
let actualPrice = itemToPriceOrTiers({
item: i,
});
@@ -132,6 +138,40 @@ export const getOptions = ({
};
}
const currentOptions = cusProduct?.options.find(
(o) => o.feature_id == i.feature_id,
);
let currentQuantity = currentOptions?.quantity;
let prorationAmount = 0;
if (currentQuantity) {
currentQuantity = currentQuantity * (i.billing_units || 1);
const curPrice = featureToCusPrice({
internalFeatureId: currentOptions?.internal_feature_id!,
cusPrices: cusProduct?.customer_prices!,
})?.price;
const curPriceAmount = priceToInvoiceAmount({
price: curPrice!,
quantity: currentQuantity,
now,
proration: finalProration,
});
const newPriceAmount = priceToInvoiceAmount({
item: i,
quantity: currentQuantity,
now,
proration: finalProration,
});
prorationAmount = new Decimal(newPriceAmount)
.minus(curPriceAmount)
.toNumber();
}
return {
feature_id: i.feature_id,
feature_name: features.find((f) => f.id == i.feature_id)?.name,
@@ -141,6 +181,12 @@ export const getOptions = ({
full_price: actualPrice?.price,
full_tiers: actualPrice?.tiers,
current_quantity: notNullish(currentQuantity)
? currentQuantity
: undefined,
proration_amount: prorationAmount,
config: i.config,
};
});
};

View File

@@ -0,0 +1,33 @@
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachConfig, FullProduct, Product, products } from "@autumn/shared";
export const getMergeCusProduct = async ({
attachParams,
products,
config,
}: {
attachParams: AttachParams;
products: FullProduct[];
config: AttachConfig;
}) => {
const { stripeCli, cusProducts, freeTrial } = attachParams;
let mergeCusProduct = undefined;
if (!config.disableMerge && !freeTrial) {
mergeCusProduct = cusProducts?.find((cp) =>
products.some((p) => p.group == cp.product.group),
);
}
let mergeSubs = await getStripeSubs({
stripeCli,
subIds: mergeCusProduct?.subscription_ids,
});
return {
mergeCusProduct,
mergeSubs,
};
};

View File

@@ -12,6 +12,7 @@ import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
APIVersion,
@@ -106,6 +107,7 @@ export const handlePaidProduct = async ({
itemSet,
anchorToUnix: billingCycleAnchorUnix,
reward: i == 0 ? reward : undefined,
now: attachParams.now,
});
let sub = subscription as Stripe.Subscription;

View File

@@ -0,0 +1,140 @@
import {
Feature,
FeatureOptions,
FullCusProduct,
FullCustomerPrice,
getFeatureInvoiceDescription,
OnIncrease,
UsagePriceConfig,
} from "@autumn/shared";
import { Stripe } from "stripe";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
shouldBillNow,
shouldProrate,
} from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { constructStripeInvoiceItem } from "@/internal/invoices/invoiceItemUtils/invoiceItemUtils.js";
import { cusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { Decimal } from "decimal.js";
export const handleQuantityUpgrade = async ({
req,
attachParams,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
cusPrice,
stripeSub,
subItem,
}: {
req: any;
attachParams: AttachParams;
cusProduct: FullCusProduct;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
cusPrice: FullCustomerPrice;
stripeSub: Stripe.Subscription;
subItem: Stripe.SubscriptionItem;
}) => {
// Manually calculate prorations...
const { features, org, logger, db } = req;
const { stripeCli, now, paymentMethod } = attachParams;
const difference = new Decimal(newOptions.quantity)
.minus(oldOptions.quantity)
.toNumber();
const onIncrease =
cusPrice.price.proration_config?.on_increase ||
OnIncrease.ProrateImmediately;
const prorate = shouldProrate(onIncrease);
if (prorate) {
const amount = priceToInvoiceAmount({
price: cusPrice.price,
quantity: difference,
proration: prorate
? {
start: stripeSub.current_period_start * 1000,
end: stripeSub.current_period_end * 1000,
}
: undefined,
now,
});
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units;
const feature = features.find(
(f: Feature) => f.internal_id == newOptions.internal_feature_id,
)!;
const product = cusProductToProduct({ cusProduct });
const invoiceItem = constructStripeInvoiceItem({
product,
amount: amount,
org: org,
price: cusPrice.price,
description: getFeatureInvoiceDescription({
feature: feature,
usage: newOptions.quantity,
billingUnits,
prodName: product.name,
isPrepaid: true,
fromUnix: now,
}),
stripeSubId: stripeSub.id,
stripeCustomerId: stripeSub.customer as string,
periodStart: Math.floor((now || Date.now()) / 1000),
periodEnd: Math.floor(stripeSub.current_period_end * 1000),
});
logger.info(
`🔥 Creating prepaid invoice item: ${invoiceItem.description} - ${amount}`,
);
await stripeCli.invoiceItems.create(invoiceItem);
if (shouldBillNow(onIncrease)) {
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
stripeCli,
stripeCusId: stripeSub.customer as string,
stripeSubId: stripeSub.id,
paymentMethod: paymentMethod || null,
});
}
}
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: newOptions.quantity,
proration_behavior: "none",
});
// Update cus ent
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let cusEnt = getRelatedCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
if (cusEnt) {
const incrementBy = new Decimal(difference).mul(billingUnits).toNumber();
logger.info(
`🔥 Incrementing feature ${cusEnt.entitlement.feature.id} balance by ${incrementBy}`,
);
await CusEntService.increment({
db,
id: cusEnt.id,
amount: incrementBy,
});
}
};

View File

@@ -1,137 +1,221 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
autumnToStripeProrationBehavior,
getStripeSubs,
getUsageBasedSub,
} from "@/external/stripe/stripeSubUtils.js";
import { getUsageBasedSub } from "@/external/stripe/stripeSubUtils.js";
import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { findPriceForFeature } from "@/internal/products/prices/priceUtils/findPriceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
ErrCode,
Feature,
FeatureOptions,
FullCusProduct,
FullCustomerEntitlement,
OnDecrease,
OnIncrease,
UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { Stripe } from "stripe";
import { AttachConfig } from "../../models/AttachFlags.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { featureToCusPrice } from "@/internal/customers/cusProducts/cusPrices/convertCusPriceUtils.js";
import { shouldProrate } from "@/internal/products/prices/priceUtils/prorationConfigUtils.js";
import { handleQuantityUpgrade } from "./handleQuantityUpgrade.js";
import { Decimal } from "decimal.js";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js";
import { getRelatedCusEnt } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
export const updateFeatureQuantity = async ({
db,
stripeCli,
const onDecreaseToStripeProration: Record<OnDecrease, string> = {
[OnDecrease.ProrateImmediately]: "always_invoice",
[OnDecrease.ProrateNextCycle]: "create_prorations",
[OnDecrease.Prorate]: "create_prorations",
[OnDecrease.None]: "none",
};
const handleQuantityDowngrade = async ({
req,
attachParams,
cusProduct,
optionsToUpdate,
config,
logger,
stripeSubs,
oldOptions,
newOptions,
subItem,
}: {
db: DrizzleCli;
stripeCli: Stripe;
req: any;
attachParams: AttachParams;
cusProduct: FullCusProduct;
optionsToUpdate: any[];
config: AttachConfig;
logger: any;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
subItem: Stripe.SubscriptionItem;
}) => {
const stripeSubs = await getStripeSubs({
stripeCli: stripeCli,
subIds: cusProduct.subscription_ids || [],
const { db, logger } = req;
const { stripeCli } = attachParams;
const cusPrice = featureToCusPrice({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;
const onDecrease =
cusPrice.price.proration_config?.on_decrease ||
OnDecrease.ProrateImmediately;
const stripeProration = onDecreaseToStripeProration[
onDecrease
] as Stripe.SubscriptionItemUpdateParams.ProrationBehavior;
logger.info(
`Handling quantity downgrade for ${newOptions.feature_id}, on decrease: ${onDecrease}, proration: ${stripeProration}`,
);
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: newOptions.quantity,
proration_behavior: stripeProration,
});
const prorationBehavior = autumnToStripeProrationBehavior({
prorationBehavior: config.proration,
});
for (const options of optionsToUpdate) {
const { new: newOptions, old: oldOptions } = options;
const subToUpdate = await getUsageBasedSub({
db,
stripeCli: stripeCli,
subIds: cusProduct.subscription_ids || [],
feature: {
internal_id: newOptions.internal_feature_id,
id: newOptions.feature_id,
} as Feature,
stripeSubs: stripeSubs,
if (!shouldProrate(onDecrease)) {
newOptions.upcoming_quantity = newOptions.quantity;
newOptions.quantity = oldOptions.quantity;
} else {
const cusEnt = getRelatedCusEnt({
cusPrice,
cusEnts: cusProduct.customer_entitlements,
});
if (!subToUpdate) {
throw new RecaseError({
message: `Failed to update quantity for ${newOptions.feature_id} to ${newOptions.quantity}`,
code: ErrCode.InternalError,
statusCode: 500,
});
}
const curPrices = cusProductToPrices({ cusProduct });
const price = findPriceForFeature({
prices: curPrices,
internalFeatureId: newOptions.internal_feature_id,
});
if (!price) {
throw new RecaseError({
message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`,
code: ErrCode.PriceNotFound,
});
}
let subItem = findStripeItemForPrice({
price,
stripeItems: subToUpdate.items.data,
});
if (!subItem) {
subItem = await stripeCli.subscriptionItems.create({
subscription: subToUpdate.id,
price: price.config.stripe_price_id as string,
quantity: newOptions.quantity,
proration_behavior: prorationBehavior,
payment_behavior: "error_if_incomplete",
});
logger.info(
`updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
);
} else {
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: newOptions.quantity,
proration_behavior: prorationBehavior,
payment_behavior: "error_if_incomplete",
});
logger.info(
`updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
);
}
// Update cus ent
const config = price.config as UsagePriceConfig;
let difference = new Decimal(newOptions.quantity)
.minus(new Decimal(oldOptions.quantity))
.mul(config.billing_units || 1);
let cusEnt = cusProduct.customer_entitlements.find(
(cusEnt: FullCustomerEntitlement) =>
cusEnt.entitlement.internal_feature_id ==
newOptions.internal_feature_id,
);
if (cusEnt) {
await CusEntService.increment({
const config = cusPrice.price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let decrementBy = new Decimal(oldOptions.quantity)
.minus(new Decimal(newOptions.quantity))
.mul(billingUnits)
.toNumber();
await CusEntService.decrement({
db,
id: cusEnt.id,
amount: difference.toNumber(),
amount: decrementBy,
});
}
}
await CusProductService.update({
db,
cusProductId: cusProduct.id,
updates: { options: optionsToUpdate.map((o) => o.new) },
});
};
export const handleUpdateFeatureQuantity = async ({
req,
attachParams,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
}: {
req: any;
attachParams: AttachParams;
cusProduct: FullCusProduct;
stripeSubs: Stripe.Subscription[];
oldOptions: FeatureOptions;
newOptions: FeatureOptions;
}) => {
const { db, logger } = req;
const { stripeCli } = attachParams;
const prorationBehavior = "always_invoice";
const subToUpdate = await getUsageBasedSub({
db,
stripeCli: stripeCli,
subIds: cusProduct.subscription_ids || [],
feature: {
internal_id: newOptions.internal_feature_id,
id: newOptions.feature_id,
} as Feature,
stripeSubs: stripeSubs,
});
const cusPrice = featureToCusPrice({
internalFeatureId: newOptions.internal_feature_id!,
cusPrices: cusProduct.customer_prices,
})!;
const price = cusPrice.price;
if (!subToUpdate) {
throw new RecaseError({
message: `Failed to update prepaid quantity for ${newOptions.feature_id} because no subscription found`,
code: ErrCode.InternalError,
statusCode: 500,
});
}
let subItem = findStripeItemForPrice({
price: price!,
stripeItems: subToUpdate.items.data,
}) as Stripe.SubscriptionItem;
// const config = price.config as UsagePriceConfig;
// let difference = new Decimal(newOptions.quantity)
// .minus(new Decimal(oldOptions.quantity))
// .mul(config.billing_units || 1);
// let cusEnt = getRelatedCusEnt({
// cusPrice,
// cusEnts: cusProduct.customer_entitlements,
// });
// if (cusEnt) {
// await CusEntService.increment({
// db,
// id: cusEnt.id,
// amount: difference.toNumber(),
// });
// }
if (newOptions.quantity < oldOptions.quantity) {
return await handleQuantityDowngrade({
req,
attachParams,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
subItem,
});
} else {
return await handleQuantityUpgrade({
req,
attachParams,
cusProduct,
stripeSubs,
oldOptions,
newOptions,
cusPrice,
stripeSub: subToUpdate,
subItem,
});
}
// if (!price) {
// throw new RecaseError({
// message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`,
// code: ErrCode.PriceNotFound,
// });
// }
// if (!subItem) {
// subItem = await stripeCli.subscriptionItems.create({
// subscription: subToUpdate.id,
// price: price.config.stripe_price_id as string,
// quantity: newOptions.quantity,
// proration_behavior: prorationBehavior,
// payment_behavior: "error_if_incomplete",
// });
// logger.info(
// `updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
// );
// } else {
// await stripeCli.subscriptionItems.update(subItem.id, {
// quantity: newOptions.quantity,
// proration_behavior: prorationBehavior,
// payment_behavior: "error_if_incomplete",
// });
// logger.info(
// `updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
// );
// }
};

View File

@@ -4,8 +4,10 @@ import {
AttachResultSchema,
} from "../../../cusProducts/AttachParams.js";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
import { updateFeatureQuantity } from "./updateFeatureQuantity.js";
import { handleUpdateFeatureQuantity } from "./updateFeatureQuantity.js";
import { AttachConfig } from "@autumn/shared";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
export const handleUpdateQuantityFunction = async ({
req,
@@ -24,14 +26,28 @@ export const handleUpdateQuantityFunction = async ({
const { curSameProduct } = attachParamToCusProducts({ attachParams });
// Check balance of each option to update...?
const stripeCli = attachParams.stripeCli;
const cusProduct = curSameProduct!;
const stripeSubs = await getStripeSubs({
stripeCli: stripeCli,
subIds: cusProduct.subscription_ids || [],
});
await updateFeatureQuantity({
for (const options of optionsToUpdate) {
await handleUpdateFeatureQuantity({
req,
attachParams,
cusProduct,
stripeSubs,
oldOptions: options.old,
newOptions: options.new,
});
}
await CusProductService.update({
db: req.db,
stripeCli: attachParams.stripeCli,
cusProduct: curSameProduct!,
optionsToUpdate: optionsToUpdate!,
config,
logger: req.logtail,
cusProductId: cusProduct.id,
updates: { options: optionsToUpdate.map((o) => o.new) },
});
res.status(200).json(

View File

@@ -194,6 +194,12 @@ const getSameProductBranch = async ({
return AttachBranch.UpdatePrepaidQuantity;
}
if (fromPreview) {
if (hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })) {
return AttachBranch.UpdatePrepaidQuantity;
}
}
// 2. If add on product
if (product.is_add_on) {
return AttachBranch.AddOn;
@@ -215,12 +221,6 @@ const getSameProductBranch = async ({
return AttachBranch.Renew;
}
if (fromPreview) {
if (hasPrepaidPrice({ prices: attachParams.prices, excludeOneOff: true })) {
return AttachBranch.UpdatePrepaidQuantity;
}
}
// Invalid, can't attach same product
throw new RecaseError({
message: `Product ${product.name} is already attached, can't attach again`,

View File

@@ -69,6 +69,7 @@ export const getContUseNewItems = async ({
amount: undefined,
description,
usage_model: priceToUsageModel(price),
feature_id: ent.feature_id,
} as PreviewLineItem;
} else {
let overage = new Decimal(usage).sub(ent.allowance!).toNumber();
@@ -100,6 +101,7 @@ export const getContUseNewItems = async ({
description,
amount,
usage_model: priceToUsageModel(price),
feature_id: ent.feature_id,
} as PreviewLineItem;
}
};

View File

@@ -6,10 +6,15 @@ 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 {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js";
import { getNewProductPreview } from "./getNewProductPreview.js";
import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js";
import { getUpdateQuantityPreview } from "./getUpdateQuantityPreview.js";
import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js";
export const attachParamsToPreview = async ({
req,
@@ -61,8 +66,8 @@ export const attachParamsToPreview = async ({
preview = await getNewProductPreview({
branch,
attachParams,
now,
logger,
config,
});
}
@@ -76,8 +81,8 @@ export const attachParamsToPreview = async ({
if (
func == AttachFunction.UpgradeDiffInterval ||
func == AttachFunction.UpdatePrepaidQuantity ||
func == AttachFunction.UpgradeSameInterval
func == AttachFunction.UpgradeSameInterval ||
func == AttachFunction.UpdatePrepaidQuantity
) {
preview = await getUpgradeProductPreview({
req,
@@ -87,6 +92,15 @@ export const attachParamsToPreview = async ({
});
}
// if (func == AttachFunction.UpdatePrepaidQuantity) {
// preview = await getUpdateQuantityPreview({
// req,
// attachParams,
// branch,
// now,
// });
// }
const { curMainProduct, curScheduledProduct } = attachParamToCusProducts({
attachParams,
});

View File

@@ -1,4 +1,4 @@
import { AttachBranch, BillingInterval } from "@autumn/shared";
import { AttachBranch, AttachConfig, BillingInterval } from "@autumn/shared";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
@@ -6,22 +6,25 @@ import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import {
addBillingIntervalUnix,
getAlignedIntervalUnix,
getNextStartOfMonthUnix,
} from "@/internal/products/prices/billingIntervalUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { getMergeCusProduct } from "../attachFunctions/addProductFlow/getMergeCusProduct.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
export const getNewProductPreview = async ({
branch,
attachParams,
now,
logger,
config,
}: {
branch: AttachBranch;
attachParams: AttachParams;
now: number;
logger: any;
config: AttachConfig;
}) => {
const { org } = attachParams;
const newProduct = attachParamsToProduct({ attachParams });
@@ -31,11 +34,21 @@ export const getNewProductPreview = async ({
anchorToUnix = getNextStartOfMonthUnix(BillingInterval.Month);
}
const { mergeCusProduct, mergeSubs } = await getMergeCusProduct({
attachParams,
products: [newProduct],
config,
});
if (mergeSubs.length > 0) {
anchorToUnix = mergeSubs[0].current_period_end * 1000;
}
const freeTrial = attachParams.freeTrial;
const items = await getItemsForNewProduct({
newProduct,
attachParams,
now,
now: attachParams.now,
anchorToUnix,
freeTrial,
logger,
@@ -43,11 +56,11 @@ export const getNewProductPreview = async ({
let dueNextCycle = null;
if (freeTrial) {
if (freeTrial || notNullish(anchorToUnix)) {
let nextCycleItems = await getItemsForNewProduct({
newProduct,
attachParams,
now,
now: attachParams.now,
logger,
});
@@ -55,9 +68,15 @@ export const getNewProductPreview = async ({
let dueAt = freeTrial
? freeTrialToStripeTimestamp({
freeTrial,
now,
now: attachParams.now,
})! * 1000
: addBillingIntervalUnix(now, minInterval);
: anchorToUnix
? getAlignedIntervalUnix({
alignWithUnix: anchorToUnix,
interval: minInterval,
now: attachParams.now,
})
: addBillingIntervalUnix(attachParams.now || Date.now(), minInterval);
dueNextCycle = {
line_items: nextCycleItems,
@@ -77,6 +96,7 @@ export const getNewProductPreview = async ({
}),
features: attachParams.features,
anchorToUnix,
now: attachParams.now || Date.now(),
});
// Next cycle at
@@ -90,7 +110,10 @@ export const getNewProductPreview = async ({
);
return price?.config.interval == minInterval;
}),
due_at: addBillingIntervalUnix(now, minInterval),
due_at: addBillingIntervalUnix(
attachParams.now || Date.now(),
minInterval,
),
};
}
}

View File

@@ -0,0 +1,220 @@
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import {
addBillingIntervalUnix,
getAlignedIntervalUnix,
} from "@/internal/products/prices/billingIntervalUtils.js";
import { getLastInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
AttachBranch,
BillingInterval,
FreeTrial,
PreviewLineItem,
Price,
UsageModel,
} from "@autumn/shared";
import Stripe from "stripe";
import {
attachParamToCusProducts,
attachParamsToProduct,
} from "../attachUtils/convertAttachParams.js";
import { intervalsAreSame } from "../attachUtils/getAttachConfig.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { Decimal } from "decimal.js";
const getNextCycleAt = ({
prices,
stripeSubs,
willCycleReset,
interval,
now,
freeTrial,
}: {
prices: Price[];
stripeSubs: Stripe.Subscription[];
willCycleReset: boolean;
interval: BillingInterval;
now?: number;
freeTrial?: FreeTrial | null;
}) => {
now = now || Date.now();
if (freeTrial) {
return {
next_cycle_at:
freeTrialToStripeTimestamp({
freeTrial,
now,
})! * 1000,
};
}
if (willCycleReset) {
const minInterval = getLastInterval({ prices });
return {
next_cycle_at: addBillingIntervalUnix(now, minInterval),
};
}
const minInterval = getLastInterval({ prices });
const nextCycleAt = getAlignedIntervalUnix({
alignWithUnix: stripeSubs[0].current_period_end * 1000,
interval: minInterval,
alwaysReturn: true,
});
return {
next_cycle_at: nextCycleAt,
};
};
export const getUpdateQuantityPreview = async ({
req,
attachParams,
branch,
now,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
branch: AttachBranch;
now: number;
}) => {
const { logtail: logger } = req;
const { stripeCli } = attachParams;
const { curMainProduct, curSameProduct } = attachParamToCusProducts({
attachParams,
});
const curCusProduct = curMainProduct!;
const stripeSubs = await getStripeSubs({
stripeCli,
subIds: curCusProduct?.subscription_ids || [],
expand: ["items.data.price.tiers"],
});
const curPreviewItems = await getItemsForCurProduct({
stripeSubs,
attachParams,
now,
logger,
});
// Get prorated amounts for new product
const newProduct = attachParamsToProduct({ attachParams });
const intervalsSame = intervalsAreSame({ attachParams });
const anchorToUnix =
intervalsSame && stripeSubs.length > 0
? stripeSubs[0].current_period_end * 1000
: undefined;
const newPreviewItems = await getItemsForNewProduct({
newProduct,
attachParams,
now,
anchorToUnix,
freeTrial: attachParams.freeTrial,
stripeSubs,
logger,
});
const lastInterval = getLastInterval({ prices: newProduct.prices });
let dueNextCycle = undefined;
if (!isFreeProduct(newProduct.prices)) {
const nextCycleAt = getNextCycleAt({
prices: newProduct.prices,
stripeSubs,
willCycleReset: !intervalsSame,
interval: lastInterval,
now,
freeTrial: attachParams.freeTrial,
});
let nextCycleItems = await getItemsForNewProduct({
newProduct,
attachParams,
interval: attachParams.freeTrial ? undefined : lastInterval,
logger,
});
dueNextCycle = {
line_items: nextCycleItems,
due_at: nextCycleAt.next_cycle_at,
};
}
let items = [...curPreviewItems, ...newPreviewItems];
for (const item of structuredClone(curPreviewItems)) {
let priceId = item.price_id;
let newItem = newPreviewItems.find((i) => i.price_id == priceId);
if (!newItem) {
continue;
}
let newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2);
let curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2);
if (newItemAmount.add(curItemAmount).eq(0)) {
items = items.filter((i) => i.price_id !== priceId);
}
}
const dueTodayAmt = items
.reduce((acc, item) => acc.plus(item.amount ?? 0), new Decimal(0))
.toDecimalPlaces(2)
.toNumber();
let options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
features: attachParams.features,
}),
features: attachParams.features,
anchorToUnix,
now,
freeTrial: attachParams.freeTrial,
cusProduct: curSameProduct,
});
items = items.filter((item) => item.amount !== 0);
if (branch == AttachBranch.UpdatePrepaidQuantity) {
items = items.filter((item) => item.usage_model == UsageModel.Prepaid);
dueNextCycle!.line_items = dueNextCycle!.line_items.filter(
(item) => item.usage_model == UsageModel.Prepaid,
);
}
let dueToday:
| {
line_items: PreviewLineItem[];
total: number;
}
| undefined = {
line_items: items,
total: dueTodayAmt,
};
if (branch == AttachBranch.SameCustomEnts) {
dueToday = undefined;
}
return {
currency: attachParams.org.default_currency,
due_today: dueToday,
due_next_cycle: dueNextCycle,
options,
};
};

View File

@@ -90,8 +90,11 @@ export const getUpgradeProductPreview = async ({
const { stripeCli } = attachParams;
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = curMainProduct!;
const { curMainProduct, curSameProduct } = attachParamToCusProducts({
attachParams,
});
const curCusProduct = curSameProduct || curMainProduct!;
const stripeSubs = await getStripeSubs({
stripeCli,
@@ -183,6 +186,7 @@ export const getUpgradeProductPreview = async ({
anchorToUnix,
now,
freeTrial: attachParams.freeTrial,
cusProduct: curCusProduct,
});
items = items.filter((item) => item.amount !== 0);
@@ -208,11 +212,12 @@ export const getUpgradeProductPreview = async ({
dueToday = undefined;
}
console.log("Due today: ", dueToday);
return {
currency: attachParams.org.default_currency,
due_today: dueToday,
due_next_cycle: dueNextCycle,
options,
};
};

View File

@@ -303,7 +303,7 @@ export const getResetBalance = ({
}: {
entitlement: Entitlement;
options: FeatureOptions | undefined | null;
relatedPrice: Price | undefined | null;
relatedPrice?: Price | null;
productQuantity?: number;
}) => {
// 1. No related price
@@ -321,9 +321,6 @@ export const getResetBalance = ({
let quantity = options?.quantity;
let billingUnits = (relatedPrice.config as UsagePriceConfig).billing_units;
if (nullish(quantity) || nullish(billingUnits)) {
// console.log("WARNING: Quantity or billing units not found");
// console.log("Entitlement:", entitlement.id, entitlement.feature_id);
// console.log("Options:", options);
return entitlement.allowance;
}

View File

@@ -60,4 +60,12 @@ export class CusPriceService {
return data as FullCustomerPrice[];
}
static async delete({ db, id }: { db: DrizzleCli; id: string }) {
const deleted = await db
.delete(customerPrices)
.where(eq(customerPrices.id, id))
.returning();
return deleted;
}
}

View File

@@ -0,0 +1,14 @@
import { FullCustomerPrice, UsagePriceConfig } from "@autumn/shared";
export const featureToCusPrice = ({
internalFeatureId,
cusPrices,
}: {
internalFeatureId: string;
cusPrices: FullCustomerPrice[];
}) => {
return cusPrices.find((cusPrice) => {
const config = cusPrice.price.config as UsagePriceConfig;
return config.internal_feature_id === internalFeatureId;
});
};

View File

@@ -4,6 +4,7 @@ import {
FullCustomerEntitlement,
FullCustomerPrice,
getFeatureInvoiceDescription,
OnDecrease,
Price,
UsagePriceConfig,
} from "@autumn/shared";

View File

@@ -27,6 +27,16 @@ import {
getProductResponse,
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
const getQuantityData = ({ cusProduct }: { cusProduct: FullCusProduct }) => {
return {
prepaid_quantities: cusProduct.options.map((o) => {
return {
quantity: o.quantity,
feature_id: o.feature_id,
};
}),
};
};
export const getCusProductResponse = async ({
cusProduct,
subs,
@@ -154,6 +164,7 @@ export const getCusProductResponse = async ({
product: fullProduct,
features,
withDisplay: false,
options: cusProduct.options,
});
return CusProductResponseSchema.parse({

View File

@@ -4,7 +4,7 @@ import {
Feature,
Organization,
} from "@autumn/shared";
import { getCusProductResponse } from "./getCusProductRepsonse.js";
import { getCusProductResponse } from "./getCusProductResponse.js";
export const processFullCusProducts = async ({
fullCusProducts,

View File

@@ -343,6 +343,7 @@ cusRouter.get(
isActive: cusProduct.status === CusProductStatus.Active,
isCustom: cusProduct.is_custom,
isCanceled: cusProduct.canceled_at !== null,
cusProductId: cusProduct.id,
}
: productV2,
features,

View File

@@ -1,15 +1,24 @@
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import Stripe from "stripe";
export const createAndFinalizeInvoice = async ({
stripeCli,
paymentMethod,
stripeCusId,
stripeSubId,
invoiceItems,
errorOnPaymentFail = true,
voidIfFailed = true,
logger,
}: {
stripeCli: Stripe;
paymentMethod: Stripe.PaymentMethod | null;
stripeCusId: string;
stripeSubId: string;
invoiceItems?: Stripe.InvoiceItemCreateParams[];
errorOnPaymentFail?: boolean;
voidIfFailed?: boolean;
logger?: any;
}) => {
const invoice = await stripeCli.invoices.create({
customer: stripeCusId,
@@ -31,5 +40,24 @@ export const createAndFinalizeInvoice = async ({
auto_advance: false,
});
if (finalInvoice.status == "open") {
const {
paid,
error,
invoice: paidInvoice,
} = await payForInvoice({
stripeCli,
invoiceId: finalInvoice.id,
paymentMethod,
logger,
errorOnFail: errorOnPaymentFail,
voidIfFailed,
});
if (paid) {
finalInvoice = paidInvoice!;
}
}
return { invoice: finalInvoice };
};

View File

@@ -1,12 +1,18 @@
import Stripe from "stripe";
import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { attachParamToCusProducts } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import {
cusProductToEnts,
cusProductToPrices,
} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getSubItemAmount } from "@/external/stripe/stripeSubUtils/getSubItemAmount.js";
import { Decimal } from "decimal.js";
import { calculateProrationAmount } from "../prorationUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import {
getBillingType,
getPriceEntitlement,
} from "@/internal/products/prices/priceUtils.js";
import { BillingType, PreviewLineItem } from "@autumn/shared";
import { priceToInvoiceDescription } from "../invoiceFormatUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
@@ -64,6 +70,8 @@ export const getItemsForCurProduct = async ({
const periodEnd = sub.current_period_end * 1000;
const ents = cusProductToEnts({ cusProduct: curCusProduct });
const ent = getPriceEntitlement(price, ents);
if (now < periodEnd) {
const finalProration = getProration({
now,
@@ -97,6 +105,7 @@ export const getItemsForCurProduct = async ({
amount: proratedAmount,
usage_model: priceToUsageModel(price),
price_id: price.id!,
feature_id: ent?.feature.id,
});
}
}

View File

@@ -8,6 +8,7 @@ import {
FreeTrial,
PreviewLineItem,
BillingType,
AttachConfig,
} from "@autumn/shared";
import { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import {
@@ -38,6 +39,7 @@ import {
attachParamToCusProducts,
} from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js";
import { getMergeCusProduct } from "@/internal/customers/attach/attachFunctions/addProductFlow/getMergeCusProduct.js";
export const getDefaultPriceStr = ({
org,
@@ -127,7 +129,6 @@ export const getItemsForNewProduct = async ({
logger: any;
}) => {
const { org, features } = attachParams;
now = now || Date.now();
const items: PreviewLineItem[] = [];
@@ -177,6 +178,7 @@ export const getItemsForNewProduct = async ({
description,
amount,
usage_model: priceToUsageModel(price),
feature_id: ent?.feature_id,
});
continue;
}
@@ -191,6 +193,7 @@ export const getItemsForNewProduct = async ({
}),
usage_model: priceToUsageModel(price),
price_id: price.id,
feature_id: ent?.feature_id,
});
continue;
}

View File

@@ -195,6 +195,10 @@ export const getPriceEntitlement = (
) => {
let config = price.config as UsagePriceConfig;
if (config.type == PriceType.Fixed) {
return null;
}
const entitlement = entitlements.find((ent) => {
let entIdMatch =
notNullish(price.entitlement_id) && price.entitlement_id == ent.id;

View File

@@ -1,8 +1,11 @@
import {
BillingType,
Feature,
FixedPriceConfig,
Infinite,
Price,
ProductItem,
UsageModel,
UsagePriceConfig,
} from "@autumn/shared";
import { isFixedPrice } from "./usagePriceUtils/classifyUsagePrice.js";
@@ -13,6 +16,8 @@ import {
calculateProrationAmount,
Proration,
} from "@/internal/invoices/prorationUtils.js";
import { itemToPriceAndEnt } from "../../product-items/productItemUtils/itemToPriceAndEnt.js";
import { isPriceItem } from "../../product-items/productItemUtils/getItemType.js";
export const getAmountForQuantity = ({
price,
@@ -64,16 +69,59 @@ export const getAmountForQuantity = ({
return amount.toDecimalPlaces(10).toNumber();
};
export const itemToInvoiceAmount = ({
item,
quantity,
overage,
}: {
item: ProductItem;
quantity?: number;
overage?: number;
}) => {
let amount = 0;
if (isPriceItem(item)) {
amount = item.price!;
}
if (!nullish(quantity) && !nullish(overage)) {
throw new Error(
`itemToInvoiceAmount: quantity or overage is required, autumn item: ${item.feature_id}`,
);
}
let price = {
config: {
usage_tiers: item.tiers || [
{
to: Infinite,
amount: item.price!,
},
],
billing_units: item.billing_units || 1,
},
} as unknown as Price;
if (item.usage_model == UsageModel.Prepaid) {
amount = getAmountForQuantity({ price, quantity: quantity! });
} else {
amount = getAmountForQuantity({ price, quantity: overage! });
}
return amount;
};
export const priceToInvoiceAmount = ({
price,
overage,
item,
quantity,
overage,
proration,
now,
}: {
price: Price;
price?: Price;
item?: ProductItem;
quantity?: number; // quantity should be multiplied by billing units
overage?: number;
quantity?: number;
proration?: Proration;
now?: number;
}) => {
@@ -81,23 +129,27 @@ export const priceToInvoiceAmount = ({
let amount = 0;
if (isFixedPrice({ price })) {
amount = (price.config as FixedPriceConfig).amount;
} else {
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! });
if (price) {
if (isFixedPrice({ price })) {
amount = (price.config as FixedPriceConfig).amount;
} 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! });
}
}
} else {
amount = itemToInvoiceAmount({ item: item!, quantity, overage });
}
if (proration) {

View File

@@ -10,6 +10,8 @@ import {
Feature,
FeatureType,
AppEnv,
OnIncrease,
UsageModel,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { notNullish, nullish } from "@/utils/genUtils.js";
@@ -147,6 +149,17 @@ const validateProductItem = ({
});
}
}
if (
item.usage_model == UsageModel.Prepaid &&
item.config?.on_increase == OnIncrease.BillImmediately
) {
throw new RecaseError({
message: `Bill immediately is not supported for prepaid just yet, contact us at hey@useautumn.com if you're interested!`,
code: ErrCode.InvalidInputs,
statusCode: StatusCodes.BAD_REQUEST,
});
}
};
export const validateProductItems = ({
newItems,

View File

@@ -10,6 +10,8 @@ import {
AttachScenario,
ProductPropertiesSchema,
BillingInterval,
FeatureOptions,
UsageModel,
} from "@autumn/shared";
import { sortProductItems } from "../../pricecn/pricecnUtils.js";
import {
@@ -31,11 +33,13 @@ export const getProductItemResponse = ({
features,
currency,
withDisplay = true,
options,
}: {
item: ProductItem;
features: Feature[];
currency?: string;
withDisplay?: boolean;
options?: FeatureOptions[];
}) => {
// 1. Get item type
let type = getItemType(item);
@@ -49,11 +53,26 @@ export const getProductItemResponse = ({
let priceData = itemToPriceOrTiers({ item });
let quantity = undefined;
let upcomingQuantity = undefined;
if (item.usage_model == UsageModel.Prepaid && notNullish(options)) {
let option = options!.find((o) => o.feature_id == item.feature_id);
quantity = option?.quantity
? option?.quantity * (item.billing_units ?? 1)
: undefined;
upcomingQuantity = option?.upcoming_quantity
? option?.upcoming_quantity * (item.billing_units ?? 1)
: undefined;
}
return ProductItemResponseSchema.parse({
type,
...item,
display: withDisplay ? display : undefined,
...priceData,
quantity,
next_cycle_quantity: upcomingQuantity,
});
};
@@ -120,6 +139,7 @@ export const getProductResponse = async ({
currency,
db,
withDisplay = true,
options,
}: {
product: FullProduct;
features: Feature[];
@@ -127,6 +147,7 @@ export const getProductResponse = async ({
currency?: string;
db?: DrizzleCli;
withDisplay?: boolean;
options?: FeatureOptions[];
}) => {
// 1. Get items with display
let items = mapToProductItems({
@@ -139,6 +160,7 @@ export const getProductResponse = async ({
features,
currency,
withDisplay,
options,
});
});

View File

@@ -103,6 +103,7 @@ export const mapToProductV2 = ({
created_at: product.created_at,
items: items,
stripe_id: product.processor?.id || null,
};
return productV2;

View File

@@ -145,21 +145,13 @@ export const createUpgradeProrationInvoice = async ({
if (shouldBillNow(onIncrease)) {
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
stripeCli,
paymentMethod,
stripeCusId: sub.customer as string,
stripeSubId: sub.id,
});
const { invoice: paidInvoice, error } = await payForInvoice({
stripeCli,
paymentMethod,
invoiceId: finalInvoice.id,
logger,
errorOnFail: true,
voidIfFailed: true,
});
logger.info(`Paid for invoice ${paidInvoice?.id}`);
return paidInvoice;
logger.info(`Paid for invoice ${finalInvoice?.id}`);
return finalInvoice;
}
return null;

View File

@@ -107,6 +107,7 @@ export const createDowngradeProrationInvoice = async ({
if (shouldBillNow(onDecrease)) {
const { invoice: finalInvoice } = await createAndFinalizeInvoice({
stripeCli,
paymentMethod: null,
stripeCusId: sub.customer as string,
stripeSubId: sub.id,
});

View File

@@ -12,8 +12,14 @@ export interface ImportCustomer {
options?: FeatureOptions[];
// business_id;name;email;active_pass_count;Stripe id;Base price;free_trial_end
}
export const parseCsv = (slug: string): Promise<any[]> => {
const path = `scripts/customers/${slug}/data2.csv`;
export const parseCsv = ({
slug,
filename,
}: {
slug: string;
filename?: string;
}): Promise<any[]> => {
const path = `scripts/customers/${slug}/${filename || "data.csv"}`;
const results: ImportCustomer[] = [];
return new Promise((resolve, reject) => {

View File

@@ -43,12 +43,17 @@ export const constructPrepaidItem = ({
billingUnits = 100,
includedUsage = 0,
isOneOff = false,
config = {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
}: {
featureId: string;
price?: number;
billingUnits?: number;
includedUsage?: number;
isOneOff?: boolean;
config?: ProductItemConfig;
}) => {
let item: ProductItem = {
feature_id: featureId,
@@ -59,6 +64,8 @@ export const constructPrepaidItem = ({
interval: isOneOff ? null : ProductItemInterval.Month,
included_usage: includedUsage,
config,
};
return item;

View File

@@ -129,11 +129,13 @@ export const constructProduct = ({
let product: ProductV2 = {
id: id_,
name: isAnnual
? `${keyToTitle(type)} (Annual)`
: interval
? `${keyToTitle(type)} (${interval})`
: keyToTitle(type),
name: id
? keyToTitle(id)
: isAnnual
? `${keyToTitle(type)} (Annual)`
: interval
? `${keyToTitle(type)} (${interval})`
: keyToTitle(type),
items,
is_add_on: isAddOn,
is_default: type == "free" && isDefault,

View File

@@ -0,0 +1,197 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js";
import {
AppEnv,
CusProduct,
CusProductStatus,
Customer,
customerProducts,
customers,
FullCusProduct,
FullCustomer,
Product,
products,
} from "@autumn/shared";
import { and, desc, eq, gt, lt, sql } from "drizzle-orm";
let cusProductsQuery = ({
orgId,
env,
inStatuses = ACTIVE_STATUSES,
lastProductId,
pageSize = 250,
}: {
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
lastProductId?: string;
pageSize?: number;
}) => {
const withStatusFilter = () => {
return inStatuses
? sql`AND cp.status = ANY(ARRAY[${sql.join(
inStatuses.map((status) => sql`${status}`),
sql`, `,
)}])`
: sql``;
};
return sql`
SELECT
cp.*,
row_to_json(prod) AS product,
-- Spread customer_prices fields + add price field
COALESCE(
json_agg(DISTINCT (
to_jsonb(cpr.*) || jsonb_build_object('price', to_jsonb(p.*))
)) FILTER (WHERE cpr.id IS NOT NULL),
'[]'::json
) AS customer_prices,
-- Spread customer_entitlements fields + add entitlement and replaceables
COALESCE(
json_agg(DISTINCT (
to_jsonb(ce.*) || jsonb_build_object(
'entitlement', (
SELECT row_to_json(ent_with_feature)
FROM (
SELECT e.*, row_to_json(f) AS feature
FROM entitlements e
JOIN features f ON e.internal_feature_id = f.internal_id
WHERE e.id = ce.entitlement_id
) AS ent_with_feature
),
'replaceables', (
SELECT COALESCE(
json_agg(row_to_json(r)) FILTER (WHERE r.id IS NOT NULL),
'[]'::json
)
FROM replaceables r
WHERE r.cus_ent_id = ce.id
)
)
)) FILTER (WHERE ce.id IS NOT NULL),
'[]'::json
) AS customer_entitlements,
-- free_trial
(
SELECT row_to_json(ft)
FROM free_trials ft
WHERE ft.id = cp.free_trial_id
) AS free_trial
FROM customer_products cp
JOIN products prod ON cp.internal_product_id = prod.internal_id
LEFT JOIN customer_prices cpr ON cpr.customer_product_id = cp.id
LEFT JOIN prices p ON cpr.price_id = p.id
LEFT JOIN customer_entitlements ce ON ce.customer_product_id = cp.id
WHERE prod.org_id = ${orgId} AND prod.env = ${env}
${withStatusFilter()}
${lastProductId ? sql`AND cp.id < ${lastProductId}` : sql``}
GROUP BY cp.id, prod.*
ORDER BY cp.id DESC
LIMIT ${pageSize}
`;
};
export const getAllFullCusProducts = async ({
db,
orgId,
env,
inStatuses = ACTIVE_STATUSES,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
}) => {
let lastProductId = "";
let allData: any[] = [];
let pageSize = 500;
while (true) {
const data = await db.execute(
cusProductsQuery({
orgId,
env,
inStatuses,
lastProductId,
pageSize,
}),
);
if (data.length === 0) break;
console.log(`Fetched ${data.length} customer products`);
allData.push(...data);
lastProductId = data[data.length - 1].id as string;
}
return allData as FullCusProduct[];
};
export const getAllCustomers = async ({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) => {
let lastCustomerId = "";
let allData: any[] = [];
let pageSize = 500;
while (true) {
const data = await db.query.customers.findMany({
where: and(
eq(customers.org_id, orgId),
eq(customers.env, env),
lastCustomerId ? lt(customers.internal_id, lastCustomerId) : undefined,
),
orderBy: [desc(customers.internal_id)],
limit: pageSize,
});
if (data.length === 0) break;
console.log(`Fetched ${data.length} customers`);
allData.push(...data);
lastCustomerId = data[data.length - 1].internal_id as string;
}
return allData as Customer[];
};
export const getAllFullCustomers = async ({
db,
orgId,
env,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
}) => {
let [customers, fullCusProducts] = await Promise.all([
getAllCustomers({ db, orgId, env }),
getAllFullCusProducts({ db, orgId, env }),
]);
let cusProdMap: Record<string, FullCusProduct[]> = {};
for (const cp of fullCusProducts) {
let internalCusId = cp.internal_customer_id;
if (!cusProdMap[internalCusId]) {
cusProdMap[internalCusId] = [];
}
cusProdMap[internalCusId].push(cp);
}
return customers.map((customer) => {
return {
...customer,
customer_products: cusProdMap[customer.internal_id] || [],
};
}) as FullCustomer[];
};

View File

@@ -0,0 +1,198 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
Customer,
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 } from "../utils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { addHours, addMonths } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { expect } from "chai";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
const testCase = "prepaid1";
export let pro = constructProduct({
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
excludeBase: true,
type: "pro",
});
describe(`${chalk.yellowBright(`attach/${testCase}: update quantity, no proration downgrade, single use`)}`, () => {
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();
let customer: Customer;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
db,
orgId: org.id,
env,
});
customer = res.customer;
testClockId = res.testClockId!;
});
const options = [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
];
it("should attach pro product to customer", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
});
it("should reduce quantity to 200 and have correct sub item quantity + cus product quantity", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 200,
},
],
});
});
it("should increase quantity to 400 and have correct sub item quantity + invoice..", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
waitForInvoice: 5000,
});
});
const newQuantity = 200;
it("should decrease quantity to 200, advance clock to next cycle and have correct balance", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: newQuantity,
},
],
});
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 30,
});
const autumnCus = await autumn.customers.get(customerId);
expect(autumnCus.features[TestFeature.Messages].balance).to.equal(
newQuantity,
);
expect(autumnCus.invoices.length).to.equal(3);
expect(autumnCus.invoices[0].total).to.equal((newQuantity / 100) * 12.5);
const cusProduct = await getMainCusProduct({
db,
internalCustomerId: customer.internal_id,
});
// console.log(cusProduct);
expect(cusProduct?.options[0].quantity).to.equal(newQuantity / 100);
expect(cusProduct?.options[0].upcoming_quantity).to.not.exist;
});
});

View File

@@ -0,0 +1,149 @@
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 } from "../utils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { addWeeks } from "date-fns";
const testCase = "prepaid2";
export let pro = constructProduct({
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
excludeBase: true,
type: "pro",
});
describe(`${chalk.yellowBright(`attach/${testCase}: upgrade quantity, prorate immediately, single use`)}`, () => {
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;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
db,
orgId: org.id,
env,
});
testClockId = res.testClockId!;
});
const options = [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
];
it("should attach pro product to customer", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
});
it("should increase advance test clock, increase quantity to 400 and have correct sub item quantity + invoice..", async function () {
const usage = Math.floor(Math.random() * 220);
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usage,
});
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addWeeks(new Date(), 2).getTime(),
waitForSeconds: 10,
});
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
usage: [
{
featureId: TestFeature.Messages,
value: usage,
},
],
waitForInvoice: 5000,
});
});
});

View File

@@ -0,0 +1,158 @@
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 } from "../utils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { addHours, addMonths, addWeeks } from "date-fns";
import { expect } from "chai";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
const testCase = "prepaid3";
export let pro = constructProduct({
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateNextCycle,
on_decrease: OnDecrease.None,
},
}),
],
excludeBase: true,
type: "pro",
});
describe(`${chalk.yellowBright(`attach/${testCase}: upgrade quantity, prorate next cycle, single use`)}`, () => {
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;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
db,
orgId: org.id,
env,
});
testClockId = res.testClockId!;
});
const options = [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
];
it("should attach pro product to customer", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
});
it("should increase advance test clock, increase quantity to 400", async function () {
const usage = Math.floor(Math.random() * 220);
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usage,
});
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
usage: [
{
featureId: TestFeature.Messages,
value: usage,
},
],
});
const customer = await autumn.customers.get(customerId);
expect(customer.invoices.length).to.equal(1);
});
it("should advance test clock to end of cycle and have correct invoice", async function () {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 10,
});
});
});

View File

@@ -0,0 +1,148 @@
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 } from "../utils.js";
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { timeout } from "@/utils/genUtils.js";
import { expect } from "chai";
import { addHours, addMonths } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
const testCase = "prepaid4";
export let pro = constructProduct({
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
excludeBase: true,
type: "pro",
});
describe(`${chalk.yellowBright(`attach/${testCase}: Testing prepaid reset`)}`, () => {
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;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro],
db,
orgId: org.id,
env,
});
testClockId = res.testClockId!;
});
const options = [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
];
it("should attach pro product to customer", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options,
});
let customer = await autumn.customers.get(customerId);
expectProductAttached({
customer,
product: pro,
});
});
// return;
const usage = 100;
it("should track usage for prepaid and have correct balance", async function () {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: usage,
});
await timeout(3000);
const customer = await autumn.customers.get(customerId);
const newBalance = options[0].quantity - usage;
expect(customer.features[TestFeature.Messages].balance).to.equal(
newBalance,
);
});
it("should advance clock to next cycle and have correct balance", async function () {
await advanceTestClock({
stripeCli,
testClockId,
advanceTo: addHours(
addMonths(new Date(), 1),
hoursToFinalizeInvoice,
).getTime(),
waitForSeconds: 25,
});
const customer = await autumn.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).to.equal(
options[0].quantity,
);
});
});

View File

@@ -0,0 +1,266 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
Customer,
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 } from "../utils.js";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expectProductAttached } from "tests/utils/expectUtils/expectProductAttached.js";
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
import { addHours, addMonths, addWeeks } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { expect } from "chai";
import { getMainCusProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
const testCase = "prepaid5";
export let prepaidAddOn = constructProduct({
type: "pro",
excludeBase: true,
id: "topup",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
isAddOn: true,
});
export let pro = constructProduct({
type: "pro",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 250,
}),
],
});
export let premium = constructProduct({
type: "premium",
items: [
constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
}),
],
});
describe(`${chalk.yellowBright(`attach/${testCase}: prepaid add on, with entities`)}`, () => {
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();
let customer: Customer;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
withTestClock: false,
});
addPrefixToProducts({
products: [pro, premium, prepaidAddOn],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, premium, prepaidAddOn],
db,
orgId: org.id,
env,
});
customer = res.customer;
// testClockId = res.testClockId!;
});
const entity1Id = "1";
const entity2Id = "2";
const entities = [
{
id: entity1Id,
name: "entity1",
feature_id: TestFeature.Users,
},
{
id: entity2Id,
name: "entity2",
feature_id: TestFeature.Users,
},
];
it("should attach pro product to entity1", async function () {
await autumn.entities.create(customerId, entities);
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity1Id,
product: pro,
stripeCli,
db,
org,
env,
});
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity1Id,
product: prepaidAddOn,
otherProducts: [pro],
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 100,
},
],
numSubs: 2,
});
});
const oldEntity2Quantity = 300;
it("should advance test clock and attach top up to entity2", async function () {
// await advanceTestClock({
// stripeCli,
// testClockId,
// advanceTo: addWeeks(new Date(), 2).getTime(),
// waitForSeconds: 10,
// });
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity2Id,
product: premium,
stripeCli,
db,
org,
env,
numSubs: 3,
});
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity2Id,
product: prepaidAddOn,
otherProducts: [premium],
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: oldEntity2Quantity,
},
],
numSubs: 4,
});
});
it("should increase prepaid add on quantity for entity1", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity1Id,
product: prepaidAddOn,
otherProducts: [pro],
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 200,
},
],
numSubs: 4,
waitForInvoice: 10000,
});
});
const newEntity2Quantity = 200;
it("should decrease prepaid add on quantity for entity2", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
entityId: entity2Id,
product: prepaidAddOn,
otherProducts: [premium],
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: newEntity2Quantity,
},
],
numSubs: 4,
waitForInvoice: 5000,
});
const entity2 = await autumn.entities.get(customerId, entity2Id);
expect(entity2.invoices.length).to.equal(2);
let creditProd = entity2.products.find((p: any) => p.id == prepaidAddOn.id);
expect(creditProd).to.exist;
const messagesItem = creditProd!.items.find(
(i: any) => i.feature_id == TestFeature.Messages,
);
expect(messagesItem).to.exist;
expect(messagesItem.quantity).to.equal(oldEntity2Quantity);
expect(messagesItem.next_cycle_quantity).to.equal(newEntity2Quantity);
});
return;
});

View File

@@ -0,0 +1,186 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
Customer,
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 } from "../utils.js";
import {
constructFeatureItem,
constructPrepaidItem,
} from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { attachAndExpectCorrect } from "tests/utils/expectUtils/expectAttach.js";
import { expect } from "chai";
const testCase = "prepaid6";
export let pro = constructProduct({
type: "pro",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
});
export let premium = constructProduct({
type: "premium",
items: [
constructPrepaidItem({
featureId: TestFeature.Messages,
billingUnits: 100,
price: 12.5,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.None,
},
}),
],
});
describe(`${chalk.yellowBright(`attach/${testCase}: prepaid add on, with entities`)}`, () => {
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();
let customer: Customer;
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
const res = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
withTestClock: false,
});
addPrefixToProducts({
products: [pro, premium],
prefix: testCase,
});
await createProducts({
autumn,
products: [pro, premium],
db,
orgId: org.id,
env,
});
customer = res.customer;
// testClockId = res.testClockId!;
});
it("should attach pro product", async function () {
await attachAndExpectCorrect({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
options: [
{
feature_id: TestFeature.Messages,
quantity: 300,
},
],
});
});
return;
// it("should advance test clock and attach premium", async function () {
// await advanceTestClock({
// stripeCli,
// testClockId,
// advanceTo: addWeeks(new Date(), 2).getTime(),
// waitForSeconds: 10,
// });
// await attachAndExpectCorrect({
// autumn,
// customerId,
// entityId: entity2Id,
// product: premium,
// stripeCli,
// db,
// org,
// env,
// numSubs: 3,
// });
// await attachAndExpectCorrect({
// autumn,
// customerId,
// entityId: entity2Id,
// product: prepaidAddOn,
// otherProducts: [premium],
// stripeCli,
// db,
// org,
// env,
// options: [
// {
// feature_id: TestFeature.Messages,
// quantity: oldEntity2Quantity,
// },
// ],
// numSubs: 4,
// });
// });
// it("should increase prepaid add on quantity for entity1", async function () {
// await attachAndExpectCorrect({
// autumn,
// customerId,
// entityId: entity1Id,
// product: prepaidAddOn,
// otherProducts: [pro],
// stripeCli,
// db,
// org,
// env,
// options: [
// {
// feature_id: TestFeature.Messages,
// quantity: 200,
// },
// ],
// numSubs: 4,
// waitForInvoice: 10000,
// });
// });
return;
});

View File

@@ -25,6 +25,7 @@ export const attachAndExpectCorrect = async ({
customerId,
entityId,
product,
otherProducts,
options,
stripeCli,
db,
@@ -40,6 +41,7 @@ export const attachAndExpectCorrect = async ({
customerId: string;
entityId?: string;
product: ProductV2;
otherProducts?: ProductV2[];
options?: FeatureOptions[];
stripeCli: Stripe;
db: DrizzleCli;
@@ -60,9 +62,10 @@ export const attachAndExpectCorrect = async ({
entity_id: entityId,
});
const optionsCopy = structuredClone(options);
const total = getAttachTotal({
preview,
options,
options: optionsCopy,
});
const { checkout_url } = await autumn.attach({
@@ -89,7 +92,7 @@ export const attachAndExpectCorrect = async ({
}
const productCount = customer.products.reduce((acc: number, p: any) => {
if (product.group == p.group) {
if (product.group == p.group && !p.is_add_on) {
return acc + 1;
} else return acc;
}, 0);
@@ -110,18 +113,23 @@ export const attachAndExpectCorrect = 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 skipInvoiceCheck =
preview.branch == AttachBranch.UpdatePrepaidQuantity && total == 0;
if (!skipInvoiceCheck) {
expectInvoicesCorrect({
customer,
first: multiInterval ? undefined : { productId: product.id, total },
second: multiInterval ? { productId: product.id, total } : undefined,
});
}
if (!skipFeatureCheck) {
expectFeaturesCorrect({
customer,
product,
usage,
options,
options: optionsCopy,
otherProducts,
});
}

View File

@@ -13,12 +13,14 @@ import { Customer, Entity } from "autumn-js";
export const expectFeaturesCorrect = ({
customer,
product,
otherProducts,
options,
usage,
entities,
}: {
customer: Customer | Entity;
product: ProductV2;
otherProducts?: ProductV2[];
options?: FeatureOptions[];
usage?: {
featureId: string;
@@ -32,6 +34,8 @@ export const expectFeaturesCorrect = ({
new Set(product.items.map((i) => i.feature_id)),
).filter(notNullish);
const otherItems = otherProducts?.flatMap((p) => p.items) || [];
for (const featureId of featureIds) {
let includedUsage: string | number = 0;
@@ -40,7 +44,7 @@ export const expectFeaturesCorrect = ({
if (item.included_usage === undefined) continue;
for (const item of items) {
for (const item of [...items, ...otherItems]) {
if (item.feature_id !== featureId) continue;
if (item.included_usage == Infinite) {
includedUsage = Infinite;

View File

@@ -6,11 +6,13 @@ import {
} from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { cusProductToPrices } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { isV4Usage } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isFreeProductV2 } from "@/internal/products/productUtils/classifyProduct.js";
import { nullish } from "@/utils/genUtils.js";
import {
AppEnv,
BillingType,
CusProductStatus,
FullCusProduct,
Organization,
@@ -211,6 +213,25 @@ export const expectSubItemsCorrect = async ({
`sub item for price: ${(price.config as any).internal_feature_id || price.config.interval} should exist`,
).to.exist;
}
// 2. If prepaid...
let billingType = getBillingType(price.config);
if (billingType == BillingType.UsageInAdvance) {
const featureId = (price.config as any).feature_id;
const options = cusProduct.options.find((o) => o.feature_id == featureId);
expect(
options,
`options should exist for prepaid price (featureId: ${featureId})`,
).to.exist;
const expectedQuantity = options?.upcoming_quantity || options?.quantity;
expect(
subItem?.quantity,
`sub item quantity for prepaid price (featureId: ${featureId}) should be ${expectedQuantity}`,
).to.equal(expectedQuantity);
continue;
}
}
expect(

View File

@@ -1,4 +1,10 @@
import { AttachPreview } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
import {
AttachBranch,
AttachPreview,
OnIncrease,
UsageModel,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
// 1. Calculate total
@@ -10,29 +16,80 @@ export const getAttachTotal = ({
options?: any;
}) => {
const dueToday = preview?.due_today;
let dueTodayTotal =
dueToday?.line_items.reduce((acc: any, item: any) => {
// Skip prepaid items that are already in the options
if (
item.usage_model == UsageModel.Prepaid &&
options.some((o: any) => o.feature_id == item.feature_id)
) {
return acc;
}
if (item.amount) {
return acc.plus(item.amount);
}
return acc;
}, new Decimal(0)) || new Decimal(0);
const isUpdatePrepaidQuantity =
preview?.branch == AttachBranch.UpdatePrepaidQuantity;
if (isUpdatePrepaidQuantity) {
dueTodayTotal = new Decimal(0);
}
for (const option of options || []) {
let previewOption = preview?.options.find(
const previewOption = preview?.options.find(
(o: any) =>
o.feature_id === option.feature_id || o.feature_id === option.featureId,
);
if (!previewOption) {
const currentQuantity = previewOption.current_quantity || 0;
const newQuantity = option.quantity || 0;
let difference = newQuantity - currentQuantity;
difference = difference / previewOption.billing_units;
const isDecrease = newQuantity < currentQuantity;
const isIncrease = newQuantity > currentQuantity;
if (isDecrease && previewOption.config.on_decrease == "none") {
option.quantity = currentQuantity;
continue;
}
const prepaidAmt = new Decimal(previewOption.price)
.times(option.quantity)
.dividedBy(previewOption.billing_units);
if (
isUpdatePrepaidQuantity &&
isIncrease &&
previewOption.config.on_increase == OnIncrease.ProrateNextCycle
) {
continue;
}
dueTodayTotal = dueTodayTotal.plus(prepaidAmt);
const differenceAmount = new Decimal(previewOption.price).times(difference);
dueTodayTotal = dueTodayTotal.plus(differenceAmount);
// Prorated difference
if (previewOption.proration_amount) {
dueTodayTotal = dueTodayTotal.plus(
new Decimal(previewOption.proration_amount),
);
}
// let previewOption = preview?.options.find(
// (o: any) =>
// o.feature_id === option.feature_id || o.feature_id === option.featureId,
// );
// if (!previewOption) {
// continue;
// }
// const prepaidAmt = new Decimal(previewOption.price)
// .times(option.quantity)
// .dividedBy(previewOption.billing_units);
// dueTodayTotal = dueTodayTotal.plus(prepaidAmt);
}
return dueTodayTotal.toDecimalPlaces(2).toNumber();

View File

@@ -11,6 +11,7 @@ export interface PreviewLineItem {
price: string;
price_id: string;
usage_model?: UsageModel;
feature_id?: string;
}
export interface AttachPreview {

View File

@@ -19,4 +19,12 @@ export const CusProductResponseSchema = z.object({
current_period_end: z.number().nullish(),
entity_id: z.string().nullish(),
items: z.array(ProductItemResponseSchema).nullish(),
prepaid_quantities: z
.array(
z.object({
quantity: z.number(),
feature_id: z.string(),
}),
)
.nullish(),
});

View File

@@ -20,6 +20,7 @@ import { CusProductStatus } from "./cusProductEnums.js";
export const FeatureOptionsSchema = z.object({
feature_id: z.string(),
quantity: z.number(), // same as prepaid
upcoming_quantity: z.number().nullish(),
adjustable_quantity: z.boolean().nullish(),
internal_feature_id: z.string().nullish(),

View File

@@ -23,6 +23,8 @@ export const ProductItemResponseSchema = z.object({
usage_model: z.nativeEnum(UsageModel).nullish(),
billing_units: z.number().nullish(), // amount per billing unit (eg. $9 / 250 units)
reset_usage_when_enabled: z.boolean().nullish(),
quantity: z.number().nullish(),
next_cycle_quantity: z.number().nullish(),
display: z
.object({

View File

@@ -15,6 +15,7 @@ export const ProductV2Schema = z.object({
free_trial: FreeTrialSchema.nullish(),
items: z.array(ProductItemSchema),
created_at: z.number(),
stripe_id: z.string().nullish(),
});
export type ProductV2 = z.infer<typeof ProductV2Schema>;

View File

@@ -20,6 +20,7 @@
"db:migrate": "cross-env NODE_OPTIONS=\"--import tsx\" pnpm exec drizzle-kit migrate --config drizzle.config.ts"
},
"dependencies": {
"date-fns": "^4.1.0",
"decimal.js": "^10.5.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.1",

View File

@@ -1,6 +1,6 @@
import { Feature } from "../models/featureModels/featureModels.js";
import { Decimal } from "decimal.js";
import { format } from "date-fns";
export const getFeatureName = ({
feature,
plural,
@@ -92,12 +92,14 @@ export const getFeatureInvoiceDescription = ({
billingUnits = 1,
prodName,
isPrepaid = false,
fromUnix,
}: {
feature: Feature;
usage: number;
billingUnits?: number | null;
prodName?: string;
isPrepaid?: boolean;
fromUnix?: number;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
@@ -119,5 +121,9 @@ export const getFeatureInvoiceDescription = ({
result = `${prodName} - ${result}`;
}
if (fromUnix) {
result = `${result} (from ${format(fromUnix, "d MMM yyyy")})`;
}
return result;
};

View File

@@ -39,7 +39,7 @@ export const AdminHover = forwardRef<
</TooltipTrigger>
{isAdmin && (
<TooltipContent
className="bg-white/50 backdrop-blur-sm shadow-sm border-1 px-2 pr-6 py-2"
className="bg-white/50 backdrop-blur-sm shadow-sm border-1 px-2 pr-6 py-2 max-w-none"
align="start"
side="bottom"
>

View File

@@ -83,6 +83,8 @@ export const shouldShowProrationConfig = ({
// If pay per use single use
const usageType = itemToUsageType({ item, features });
if (item.usage_model == UsageModel.Prepaid) return true;
// if (
// usageType == ProductItemFeatureType.SingleUse &&
// item.usage_model == UsageModel.Prepaid

View File

@@ -1,3 +1,5 @@
import { notNullish } from "@/utils/genUtils";
export const ProductOptions = ({
options,
setOptions,
@@ -23,6 +25,11 @@ export const ProductOptions = ({
{option.quantity !== undefined && option.quantity !== null && (
<div className="flex items-center gap-2">
<p className="text-sm text-t1 font-mono">{option.quantity}</p>
{notNullish(option.upcoming_quantity) && (
<p className="text-sm text-t3 font-mono">
(Upcoming: {option.upcoming_quantity})
</p>
)}
</div>
)}
</div>

View File

@@ -3,6 +3,9 @@ import React from "react";
import { AttachNewItems } from "./attach-preview/AttachNewItems";
import { DueToday } from "./attach-preview/DueToday";
import { DueNextCycle } from "./attach-preview/DueNextCycle";
import { UpdateQuantity } from "./attach-preview/UpdateQuantity";
import { AttachBranch } from "@autumn/shared";
import { OptionsInput } from "./attach-preview/OptionsInput";
export const AttachPreviewDetails = () => {
const { org, attachState } = useProductContext();
@@ -12,10 +15,21 @@ export const AttachPreviewDetails = () => {
return null;
}
const branch = preview.branch;
const isUpdatePrepaidQuantity = branch == AttachBranch.UpdatePrepaidQuantity;
return (
<React.Fragment>
<DueToday />
<AttachNewItems />
{/* Options */}
{/* <OptionsInput /> */}
<UpdateQuantity />
{!isUpdatePrepaidQuantity && (
<>
<DueToday />
<AttachNewItems />
</>
)}
<DueNextCycle />
</React.Fragment>
);

View File

@@ -26,8 +26,9 @@ export const DueNextCycle = () => {
if (!preview.due_next_cycle) return null;
if (
(!preview.due_next_cycle.line_items?.length && !preview.options?.length) ||
preview.options.every((option: any) => option.full_price == option.price)
!preview.due_next_cycle.line_items?.length &&
!preview.options?.length
// || preview.options.every((option: any) => option.full_price == option.price)
)
return null;

View File

@@ -67,7 +67,7 @@ export const DueToday = () => {
</PriceItem>
);
})}
<AttachNewItems />
{/* <AttachNewItems /> */}
{options.length > 0 &&
options.map((option: any, index: number) => {
const { feature_name, billing_units, quantity, price } = option;
@@ -76,18 +76,6 @@ export const DueToday = () => {
<span>
{product.name} - {feature_name}
</span>
{/* <QuantityInput
key={feature_name}
value={quantity ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newOptions = [...options];
newOptions[index].quantity =
parseInt(e.target.value) * billing_units;
setOptions(newOptions);
}}
>
</QuantityInput> */}
<div className="flex items-center gap-2 ">
<Input
type="number"

View File

@@ -0,0 +1,51 @@
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { Input } from "@/components/ui/input";
import { notNullish } from "@/utils/genUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
export const OptionsInput = () => {
const { attachState, product, org } = useProductContext();
const { preview, options, setOptions } = attachState;
if (!options || options.length == 0) return null;
return (
<div className="flex flex-col">
{options.length > 0 &&
options.map((option: any, index: number) => {
const { feature_name, billing_units, quantity, price } = option;
return (
<PriceItem key={feature_name}>
<span>
{product.name} - {feature_name}
</span>
<div className="flex items-center gap-2 ">
<Input
type="number"
value={notNullish(quantity) ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newOptions = [...options];
newOptions[index].quantity =
parseInt(e.target.value) * billing_units;
setOptions(newOptions);
}}
className="w-12 h-7"
/>
{/* <span className="text-muted-foreground truncate max-w-40">
×{" "}
{formatAmount({
defaultCurrency: currency,
amount: price,
maxFractionDigits: 2,
})}{" "}
per {billing_units === 1 ? " " : billing_units} {feature_name}
</span> */}
</div>
</PriceItem>
);
})}
</div>
);
};

View File

@@ -0,0 +1,85 @@
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { Input } from "@/components/ui/input";
import { notNullish } from "@/utils/genUtils";
import { formatAmount } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { AttachBranch } from "@autumn/shared";
export const UpdateQuantity = () => {
const { attachState, product, org } = useProductContext();
const { preview, options, setOptions } = attachState;
if (preview.branch !== AttachBranch.UpdatePrepaidQuantity) {
return null;
}
const currency = org?.default_currency || "USD";
const getTotalPrice = () => {
return options.reduce((acc: number, option: any) => {
const currentQuantity = option.current_quantity || 0;
const newQuantity = option.quantity || 0;
let difference = newQuantity - currentQuantity;
difference = difference / option.billing_units;
const isDecrease = newQuantity < currentQuantity;
if (isDecrease && option.config.on_decrease == "none") {
return acc;
}
return acc + option.price * difference;
}, 0);
};
return (
<div className="flex flex-col w-full">
<p className="text-t2 font-semibold mb-2">Update prepaid quantity</p>
{options.length > 0 &&
options.map((option: any, index: number) => {
const { feature_name, billing_units, quantity, price } = option;
return (
<PriceItem key={feature_name}>
<span>
{product.name} - {feature_name}
</span>
<div className="flex items-center gap-2 ">
<Input
type="number"
value={notNullish(quantity) ? quantity / billing_units : ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newOptions = [...options];
newOptions[index].quantity =
parseInt(e.target.value) * billing_units;
setOptions(newOptions);
}}
className="w-12 h-7"
/>
<span className="text-muted-foreground truncate max-w-40">
×{" "}
{formatAmount({
defaultCurrency: currency,
amount: price,
maxFractionDigits: 2,
})}{" "}
per {billing_units === 1 ? " " : billing_units} {feature_name}
</span>
</div>
</PriceItem>
);
})}
<PriceItem className="font-bold mt-2">
<span>Total:</span>
<span>
{formatAmount({
amount: getTotalPrice(),
defaultCurrency: currency,
maxFractionDigits: 2,
})}
</span>
</PriceItem>
</div>
);
};

View File

@@ -109,19 +109,9 @@ export const useAttachState = ({
free_trial: initialProductRef.current?.free_trial || null,
});
console.log(
"Initial product ref",
JSON.stringify(initialProductRef.current, null, 2),
);
console.log("Sorted product", JSON.stringify(sortedProduct, null, 2));
setItemsChanged(hasItemsChanged);
}, [product]);
useEffect(() => {
console.log("Initial product ref", initialProductRef.current);
}, []);
const getButtonDisabled = () => {
if (product?.isActive && !itemsChanged && !flags.isCanceled) {
if (flags.hasPrepaid) {
@@ -161,8 +151,6 @@ export const useAttachState = ({
};
const getButtonText = () => {
console.log("Is prepaid:", flags.hasPrepaid);
console.log("Items changed:", itemsChanged);
if (product?.isActive && !itemsChanged) {
if (flags.isOneOff) {
return "Attach Product";

View File

@@ -18,7 +18,23 @@ export const ManageProduct = ({
<div className="flex items-center justify-between pl-10 pr-10">
<div className="col-span-2 flex">
<div className="flex flex-col gap-1 justify-center w-full whitespace-nowrap">
<AdminHover texts={[product.internal_id!]} hide={hideAdminHover}>
<AdminHover
texts={[
{
key: "internal_product_id",
value: product.internal_id!,
},
{
key: "stripe_id",
value: product.stripe_id || "N/A",
},
{
key: "customer_product_id",
value: product.cusProductId || "N/A",
},
]}
hide={hideAdminHover}
>
<h2 className="text-lg font-medium w-fit whitespace-nowrap">
{product.name}
</h2>

View File

@@ -2,22 +2,12 @@ import {
Feature,
FeatureType,
FeatureUsageType,
Infinite,
ProductItemFeatureType,
ProductItemInterval,
TierInfinite,
} from "@autumn/shared";
import { useEffect, useState } from "react";
import { useProductContext } from "@/views/products/product/ProductContext";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useProductItemContext } from "./ProductItemContext";
@@ -27,7 +17,6 @@ import {
} from "@/utils/product/productItemUtils";
import { ConfigWithFeature } from "./components/ConfigWithFeature";
import FixedPriceConfig from "./components/ConfigFixedPrice";
import { getFeature } from "@/utils/product/entitlementUtils";
import { isFeaturePriceItem, isPriceItem } from "@/utils/product/getItemType";
export const ProductItemConfig = () => {

View File

@@ -1,13 +1,23 @@
import { OnDecrease, UsageModel } from "@autumn/shared";
import { useProductItemContext } from "../../../ProductItemContext";
import { ProrationSelect } from "./ProrationSelect";
import { nullish } from "zod/v4";
import { nullish } from "@/utils/genUtils";
const optionToText = (option: OnDecrease) => {
const optionToText = ({
option,
usageModel,
}: {
option: OnDecrease;
usageModel: UsageModel;
}) => {
switch (option) {
case OnDecrease.Prorate:
return "Prorate";
case OnDecrease.None:
if (usageModel == UsageModel.Prepaid) {
return "No proration (balance will be kept till next cycle)";
}
return "No proration (usage will be kept till next cycle)";
}
};
@@ -31,18 +41,6 @@ export const OnDecreaseSelect = () => {
return OnDecrease.None;
};
// useEffect(() => {
// if (!item.config?.on_decrease) {
// setItem({
// ...item,
// config: {
// ...item.config,
// on_decrease: OnDecrease.Prorate,
// },
// });
// }
// }, [item]);
const text =
item.usage_model == UsageModel.PayPerUse
? "On usage decrease"
@@ -60,7 +58,9 @@ export const OnDecreaseSelect = () => {
config: { ...item.config, on_decrease: value },
});
}}
optionToText={optionToText}
optionToText={(option: OnDecrease) =>
optionToText({ option, usageModel: item.usage_model })
}
options={[OnDecrease.Prorate, OnDecrease.None]}
/>
</div>

View File

@@ -34,7 +34,14 @@ export const OnIncreaseSelect = () => {
setItem({ ...item, config: { ...item.config, on_increase: value } })
}
optionToText={optionToText}
options={Object.values(OnIncrease)}
options={Object.values(OnIncrease).filter((o) => {
if (
item.usage_model == UsageModel.Prepaid &&
o == OnIncrease.BillImmediately
)
return false;
return true;
})}
/>
</div>
);