feat: improving update quantity with proration configs
This commit is contained in:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
106
server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts
vendored
Normal file
106
server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handlePrepaidPrices.ts
vendored
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
// );
|
||||
// }
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FullCustomerEntitlement,
|
||||
FullCustomerPrice,
|
||||
getFeatureInvoiceDescription,
|
||||
OnDecrease,
|
||||
Price,
|
||||
UsagePriceConfig,
|
||||
} from "@autumn/shared";
|
||||
|
||||
@@ -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({
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export const mapToProductV2 = ({
|
||||
created_at: product.created_at,
|
||||
|
||||
items: items,
|
||||
stripe_id: product.processor?.id || null,
|
||||
};
|
||||
|
||||
return productV2;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
197
server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts
Normal file
197
server/src/utils/scriptUtils/getAll/getAllAutumnCustomers.ts
Normal 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[];
|
||||
};
|
||||
198
server/tests/attach/prepaid/prepaid1.ts
Normal file
198
server/tests/attach/prepaid/prepaid1.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
149
server/tests/attach/prepaid/prepaid2.ts
Normal file
149
server/tests/attach/prepaid/prepaid2.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
158
server/tests/attach/prepaid/prepaid3.ts
Normal file
158
server/tests/attach/prepaid/prepaid3.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
148
server/tests/attach/prepaid/prepaid4.ts
Normal file
148
server/tests/attach/prepaid/prepaid4.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
266
server/tests/attach/prepaid/prepaid5.ts
Normal file
266
server/tests/attach/prepaid/prepaid5.ts
Normal 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;
|
||||
});
|
||||
186
server/tests/attach/prepaid/prepaid6.ts
Normal file
186
server/tests/attach/prepaid/prepaid6.ts
Normal 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;
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PreviewLineItem {
|
||||
price: string;
|
||||
price_id: string;
|
||||
usage_model?: UsageModel;
|
||||
feature_id?: string;
|
||||
}
|
||||
|
||||
export interface AttachPreview {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user