adding attach preview

This commit is contained in:
John Yeo
2025-06-03 08:07:41 +01:00
parent f109524377
commit efeaace7b0
100 changed files with 2382 additions and 1227 deletions

View File

@@ -7,6 +7,21 @@ import { StatusCodes } from "http-status-codes";
import { CusService } from "@/internal/customers/CusService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
export const getStripeCus = async ({
stripeCli,
stripeId,
}: {
stripeCli: Stripe;
stripeId: string;
}) => {
try {
const stripeCus = await stripeCli.customers.retrieve(stripeId);
return stripeCus as Stripe.Customer;
} catch (error) {
return undefined;
}
};
export const createStripeCusIfNotExists = async ({
db,
org,

View File

@@ -58,7 +58,7 @@ export const createStripeSubThroughInvoice = async ({
// ...paymentMethodData,
customer: customer.processor.id,
items: subItems as any,
trial_end: freeTrialToStripeTimestamp(freeTrial),
trial_end: freeTrialToStripeTimestamp({ freeTrial }),
metadata,
add_invoice_items: invoiceItems,
collection_method: "send_invoice",

View File

@@ -248,6 +248,21 @@ export const subIsPrematurelyCanceled = (sub: Stripe.Subscription) => {
);
};
export const autumnToStripeProrationBehavior = ({
prorationBehavior,
}: {
prorationBehavior: ProrationBehavior;
}) => {
switch (prorationBehavior) {
case ProrationBehavior.Immediately:
return "always_invoice";
case ProrationBehavior.NextBilling:
return "create_prorations";
case ProrationBehavior.None:
return "none";
}
};
export const getStripeProrationBehavior = ({
org,
prorationBehavior,

View File

@@ -76,7 +76,7 @@ export const createStripeSub = async ({
return await stripeCli.invoices.createPreview({
subscription_details: {
items: subItems as any,
trial_end: freeTrialToStripeTimestamp(freeTrial),
trial_end: freeTrialToStripeTimestamp({ freeTrial }),
billing_cycle_anchor: billingCycleAnchorUnix
? Math.floor(billingCycleAnchorUnix / 1000)
: undefined,
@@ -91,7 +91,7 @@ export const createStripeSub = async ({
...paymentMethodData,
customer: customer.processor.id,
items: subItems as any,
trial_end: freeTrialToStripeTimestamp(freeTrial),
trial_end: freeTrialToStripeTimestamp({ freeTrial }),
payment_behavior: "error_if_incomplete",
add_invoice_items: invoiceItems,
collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",

View File

@@ -50,8 +50,6 @@ export const getSubItemAmount = ({
quantity,
});
console.log("Tiered amount:", tieredAmount);
return tieredAmount;
}

View File

@@ -8,6 +8,22 @@ import {
} from "@autumn/shared";
import Stripe from "stripe";
export const findSubItemForPrice = ({
price,
subItems,
}: {
price: Price;
subItems: Stripe.SubscriptionItem[];
}) => {
return subItems.find((si: Stripe.SubscriptionItem) => {
const config = price.config as UsagePriceConfig;
return (
config.stripe_price_id == si.price?.id ||
config.stripe_product_id == si.price?.product
);
});
};
export const findPriceInStripeItems = ({
prices,
subItem,

View File

@@ -1,7 +1,7 @@
import { OrgService } from "@/internal/orgs/OrgService.js";
import { AuthType, LoggerAction, Organization } from "@autumn/shared";
import express from "express";
import stripe from "stripe";
import stripe, { Stripe } from "stripe";
import { handleCheckoutSessionCompleted } from "./webhookHandlers/handleCheckoutCompleted.js";
import { handleSubscriptionUpdated } from "./webhookHandlers/handleSubUpdated.js";
import { handleSubscriptionDeleted } from "./webhookHandlers/handleSubDeleted.js";
@@ -16,9 +16,22 @@ import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubS
import { format } from "date-fns";
import { createLogtailWithContext } from "../logtail/logtailUtils.js";
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
export const stripeWebhookRouter = express.Router();
const logStripeWebhook = ({
req,
event,
}: {
req: ExtendedRequest;
event: Stripe.Event;
}) => {
console.log(
`${chalk.yellow("STRIPE").padEnd(18)} ${event.type.padEnd(30)} ${req.org.slug} | ${event.id}`,
);
};
stripeWebhookRouter.post(
"/:orgId/:env",
express.raw({ type: "application/json" }),
@@ -30,13 +43,23 @@ stripeWebhookRouter.post(
const { db } = request;
let org: Organization;
try {
org = await OrgService.get({ db: request.db, orgId });
} catch (error) {
const data = await OrgService.getWithFeatures({
db: request.db,
orgId,
env,
});
if (!data) {
response.status(200).send(`Org ${orgId} not found`);
return;
}
request.org = data.org;
request.features = data.features;
request.env = env;
org = data.org;
if (!org.stripe_config) {
console.log(`Org ${orgId} does not have a stripe config`);
response.status(200).send(`Org ${orgId} does not have a stripe config`);
@@ -51,8 +74,6 @@ stripeWebhookRouter.post(
return;
}
// event = JSON.parse(request.body);
try {
request.body = JSON.parse(request.body);
request.authType = AuthType.Stripe;
@@ -60,6 +81,8 @@ stripeWebhookRouter.post(
console.log("Error parsing body", error);
}
logStripeWebhook({ req: request, event });
const logger = createLogtailWithContext({
action: LoggerAction.StripeWebhook,
event_type: event.type,
@@ -69,13 +92,13 @@ stripeWebhookRouter.post(
env,
});
console.log(
`${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${chalk.yellow(
"Stripe Webhook: ",
)} ${request.url} ${request.url.includes("live") ? " " : ""}| ${
event?.type
} | ID: ${event?.id}`,
);
// console.log(
// `${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${chalk.yellow(
// "Stripe Webhook: ",
// )} ${request.url} ${request.url.includes("live") ? " " : ""}| ${
// event?.type
// } | ID: ${event?.id}`,
// );
try {
switch (event.type) {
@@ -117,6 +140,7 @@ stripeWebhookRouter.post(
case "checkout.session.completed":
const checkoutSession = event.data.object;
await handleCheckoutSessionCompleted({
req: request,
db,
checkoutSession,
org,

View File

@@ -316,6 +316,7 @@ export const handleCheckoutSessionCompleted = async ({
: undefined
: undefined,
scenario: AttachScenario.New,
logger,
});
}

View File

@@ -27,46 +27,43 @@ import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/han
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getCusPaymentMethod } from "../stripeCusUtils.js";
import { createStripeCli } from "../utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { webhookToAttachParams } from "../webhookUtils/webhookUtils.js";
const handleCusProductDeleted = async ({
req,
db,
stripeCli,
cusProduct,
subscription,
logger,
env,
org,
prematurelyCanceled,
}: {
req: ExtendedRequest;
db: DrizzleCli;
stripeCli: Stripe;
cusProduct: FullCusProduct;
subscription: Stripe.Subscription;
logger: any;
env: AppEnv;
org: Organization;
prematurelyCanceled: boolean;
}) => {
if (
!cusProduct ||
cusProduct.customer!.env !== env ||
cusProduct.customer!.org_id !== org.id
) {
console.log(
" ⚠️ customer product not found / env mismatch / org mismatch",
);
return;
}
const { org, env } = req;
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: cusProduct.customer!.processor?.id,
});
const customer = cusProduct.customer!;
if (cusProduct.internal_entity_id) {
let customer = cusProduct.customer;
let usagePrices = cusProduct.customer_prices.filter(
(cp: FullCustomerPrice) =>
getBillingType(cp.price.config!) === BillingType.UsageInArrear,
);
if (usagePrices.length > 0) {
// Create invoice for remaining usage charges
logger.info(
`Customer ${customer!.name} (${customer!.id}), Entity: ${cusProduct.internal_entity_id}`,
);
@@ -77,20 +74,12 @@ const handleCusProductDeleted = async ({
db,
curCusProduct: cusProduct,
logger,
attachParams: {
customer: cusProduct.customer!,
org,
invoiceOnly: false,
// PLACEHOLDERS
products: [],
prices: [],
entitlements: [],
features: [],
freeTrial: null,
optionsList: [],
entities: [],
},
attachParams: webhookToAttachParams({
req,
stripeCli,
paymentMethod,
cusProduct,
}),
newSubs: [subscription],
});
}
@@ -182,6 +171,7 @@ const handleCusProductDeleted = async ({
org,
env,
curCusProduct: curMainProduct || undefined,
logger,
});
await cancelCusProductSubscriptions({
@@ -215,19 +205,24 @@ export const handleSubscriptionDeleted = async ({
env,
});
const stripeCli = createStripeCli({
org,
env,
});
if (activeCusProducts.length === 0) {
console.log(
` ⚠️ no customer products found with stripe sub id: ${subscription.id}`,
);
if (subscription.livemode) {
throw new RecaseError({
message: `Stripe subscription.deleted (live): no customer products found, subscription: ${subscription.id}`,
code: ErrCode.NoActiveCusProducts,
statusCode: 200,
});
logger.warn(
`subscription.deleted: ${subscription.id} - no customer products found`,
);
return;
}
}
if (subscription.cancellation_details?.comment === "autumn_upgrade") {
logger.info(
`sub.deleted: ${subscription.id} from autumn upgrade, skipping`,
);
return;
}
@@ -240,11 +235,10 @@ export const handleSubscriptionDeleted = async ({
handleCusProductDeleted({
req,
db,
stripeCli,
cusProduct,
subscription,
logger,
env,
org,
prematurelyCanceled,
}),
);

View File

@@ -206,6 +206,7 @@ export const handleSubscriptionUpdated = async ({
},
startsAt: fullSub.current_period_end * 1000,
sendWebhook: false,
logger,
});
if (fullCusProduct) {

View File

@@ -0,0 +1,49 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
cusProductToEnts,
cusProductToPrices,
cusProductToProduct,
} from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
AppEnv,
Entity,
Feature,
FullCusProduct,
Organization,
} from "@autumn/shared";
import Stripe from "stripe";
export const webhookToAttachParams = ({
req,
stripeCli,
paymentMethod,
cusProduct,
entities,
}: {
req: ExtendedRequest;
stripeCli: Stripe;
paymentMethod?: Stripe.PaymentMethod | null;
cusProduct: FullCusProduct;
entities?: Entity[];
}): AttachParams => {
const fullProduct = cusProductToProduct({ cusProduct });
return {
stripeCli,
paymentMethod,
customer: cusProduct.customer!,
org: req.org,
products: [fullProduct],
prices: cusProductToPrices({ cusProduct }),
entitlements: cusProductToEnts({ cusProduct }),
features: req.features,
freeTrial: cusProduct.free_trial || null,
optionsList: cusProduct.options,
cusProducts: [cusProduct],
internalEntityId: cusProduct.internal_entity_id || undefined,
entities: entities || [],
};
};

View File

@@ -108,11 +108,7 @@ const init = async () => {
const methodColor: any = methodToColor[method] || chalk.gray;
console.log(
`${chalk.gray(format(new Date(), "dd MMM HH:mm:ss"))} ${methodColor(
method,
)} ${chalk.white(path)}`,
);
console.log(`${methodColor(method).padEnd(18)} ${path}`);
next();
});

View File

@@ -3,7 +3,9 @@ import { isFeaturePriceItem } from "@/internal/products/product-items/getItemTyp
import {
APIVersion,
BillingInterval,
Feature,
FreeTrial,
FullCusProduct,
FullCustomerEntitlement,
Organization,
@@ -14,6 +16,8 @@ import {
import { getCheckPreview } from "./getCheckPreview.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getProration } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
export const getBooleanEntitledResult = async ({
db,
@@ -78,20 +82,58 @@ export const getBooleanEntitledResult = async ({
export const getOptions = ({
prodItems,
features,
anchorToUnix,
proration,
now,
freeTrial,
}: {
prodItems: ProductItem[];
features: Feature[];
anchorToUnix?: number;
proration?: {
start: number;
end: number;
};
now?: number;
freeTrial?: FreeTrial | null;
}) => {
now = now || Date.now();
return prodItems
.filter((i) => isFeaturePriceItem(i) && i.usage_model == UsageModel.Prepaid)
.map((i) => {
let priceData = itemToPriceOrTiers(i);
const finalProration = getProration({
anchorToUnix,
proration,
interval: (i.interval || BillingInterval.OneOff) as BillingInterval,
now,
});
let priceData = itemToPriceOrTiers({
item: i,
proration: finalProration,
now,
});
let actualPrice = itemToPriceOrTiers({
item: i,
});
if (freeTrial) {
priceData = {
price: 0,
tiers: undefined,
};
}
return {
feature_id: i.feature_id,
feature_name: features.find((f) => f.id == i.feature_id)?.name,
billing_units: i.billing_units,
included_usage: i.included_usage || 0,
...priceData,
full_price: actualPrice?.price,
full_tiers: actualPrice?.tiers,
};
});
};

View File

@@ -13,10 +13,10 @@ import {
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
import {
AppEnv,
BillingType,
Customer,
Feature,
FullCusProduct,
FullCustomer,

View File

@@ -99,7 +99,7 @@ export const initCusProduct = ({
let trialEnds = trialEndsAt;
if (!trialEndsAt && freeTrial) {
trialEnds = freeTrialToStripeTimestamp(freeTrial)! * 1000;
trialEnds = freeTrialToStripeTimestamp({ freeTrial })! * 1000;
}
return {
@@ -270,6 +270,7 @@ export const createFullCusProduct = async ({
isDowngrade = false,
scenario = "default",
sendWebhook = true,
logger,
}: {
db: DrizzleCli;
attachParams: InsertCusProductParams;
@@ -293,16 +294,10 @@ export const createFullCusProduct = async ({
isDowngrade?: boolean;
scenario?: string;
sendWebhook?: boolean;
logger: any;
}) => {
disableFreeTrial = attachParams.disableFreeTrial || disableFreeTrial;
const logger = createLogtailWithContext({
action: LoggerAction.CreateFullCusProduct,
org_slug: attachParams.org.slug,
org_id: attachParams.org.id,
attachParams,
});
let { customer, product, prices, entitlements, optionsList, freeTrial, org } =
attachParams;
@@ -369,8 +364,6 @@ export const createFullCusProduct = async ({
cusEnts.push(cusEnt);
}
// Perform deductions on new cus ents...
let deductedCusEnts = addExistingUsagesToCusEnts({
cusEnts: cusEnts,
entitlements: entitlements,

View File

@@ -17,6 +17,7 @@ export const handleAddFreeProduct = async ({
res: any;
attachParams: AttachParams;
}) => {
const logger = req.logtail;
const { customer, products } = attachParams;
console.log(
@@ -29,10 +30,10 @@ export const handleAddFreeProduct = async ({
for (const product of products) {
await createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
subscriptionId: undefined,
billLaterOnly: false,
logger,
});
}

View File

@@ -184,6 +184,7 @@ export const handleBillNowPrices = async ({
: undefined,
carryExistingUsages,
scenario: AttachScenario.New,
logger,
}),
);
}
@@ -414,9 +415,9 @@ export const handleOneOffPrices = async ({
batchInsert.push(
createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
lastInvoiceId: stripeInvoice.id,
logger,
}),
);
}
@@ -529,6 +530,7 @@ export const handleAddProduct = async ({
billLaterOnly: true,
carryExistingUsages,
keepResetIntervals,
logger,
}),
);
}

View File

@@ -74,7 +74,7 @@ export const handleCreateCheckout = async ({
? {
trial_end:
freeTrial && !attachParams.disableFreeTrial
? freeTrialToStripeTimestamp(freeTrial)
? freeTrialToStripeTimestamp({ freeTrial })
: undefined,
// metadata: subMeta,
billing_cycle_anchor: billingCycleAnchorUnixSeconds,

View File

@@ -104,7 +104,7 @@ const initCusEntNextResetAt = ({
// 4. Calculate next reset at...
let nextResetAtCalculated = null;
let trialEndTimestamp = freeTrialToStripeTimestamp(freeTrial);
let trialEndTimestamp = freeTrialToStripeTimestamp({ freeTrial });
if (
freeTrial &&
applyTrialToEntitlement(entitlement, freeTrial) &&

View File

@@ -1,36 +1,20 @@
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
import {
getStripeSchedules,
getStripeSubs,
} from "@/external/stripe/stripeSubUtils.js";
import { getCusProductsWithStripeSubIds } from "@/internal/customers/change-product/handleDowngrade.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { cancelCurSubs } from "@/internal/customers/change-product/handleDowngrade/cancelCurSubs.js";
import {
cancelFutureProductSchedule,
getScheduleIdsFromCusProducts,
} from "@/internal/customers/change-product/scheduleUtils.js";
import { updateScheduledSubWithNewItems } from "@/internal/customers/change-product/scheduleUtils/updateScheduleWithNewItems.js";
import {
AttachParams,
AttachResultSchema,
} from "@/internal/customers/cusProducts/AttachParams.js";
import {
APIVersion,
AttachScenario,
FullCusProduct,
SuccessCode,
} from "@autumn/shared";
import {
handleNewScheduleForItemSet,
scheduleStripeSub,
} from "./scheduleStripeSub.js";
import { APIVersion, AttachScenario, SuccessCode } from "@autumn/shared";
import { handleNewScheduleForItemSet } from "./scheduleStripeSub.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
attachToInsertParams,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { cusProductsToSchedules } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js";
import Stripe from "stripe";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
@@ -143,6 +127,7 @@ export const handleScheduleFunction = async ({
disableFreeTrial: true,
isDowngrade: true,
scenario: newProductFree ? AttachScenario.Cancel : AttachScenario.Downgrade,
logger,
});
// 8. Updating current cus product canceled_at...

View File

@@ -42,6 +42,7 @@ export const handleEntsChangedFunction = async ({
disableFreeTrial: false,
keepResetIntervals: true,
carryExistingUsages,
logger,
});
logger.info("✅ Successfully updated entitlements for product");

View File

@@ -1,41 +1,52 @@
import { DrizzleCli } from "@/db/initDrizzle.js";
import {
autumnToStripeProrationBehavior,
getStripeSubs,
getUsageBasedSub,
} from "@/external/stripe/stripeSubUtils.js";
import { findSubItemForPrice } 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 {
Customer,
ErrCode,
Feature,
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { Stripe } from "stripe";
import { AttachConfig } from "../../models/AttachFlags.js";
export const updateFeatureQuantity = async ({
db,
stripeCli,
cusProduct,
optionsToUpdate,
config,
logger,
}: {
db: DrizzleCli;
stripeCli: Stripe;
cusProduct: FullCusProduct;
optionsToUpdate: any[];
config: AttachConfig;
logger: any;
}) => {
const stripeSubs = await getStripeSubs({
stripeCli: stripeCli,
subIds: cusProduct.subscription_ids || [],
});
const prorationBehavior = autumnToStripeProrationBehavior({
prorationBehavior: config.proration,
});
for (const options of optionsToUpdate) {
const { new: newOptions, old: oldOptions } = options;
const subToUpdate = await getUsageBasedSub({
@@ -57,44 +68,53 @@ export const updateFeatureQuantity = async ({
});
}
// Update subscription
// Get price
const relatedPrice = cusProduct.customer_prices.find(
(cusPrice: FullCustomerPrice) =>
(cusPrice.price.config as UsagePriceConfig).internal_feature_id ==
newOptions.internal_feature_id,
);
const curPrices = cusProductToPrices({ cusProduct });
const price = findPriceForFeature({
prices: curPrices,
internalFeatureId: newOptions.internal_feature_id,
});
let config = relatedPrice?.price.config as UsagePriceConfig;
if (!price) {
throw new RecaseError({
message: `updateFeatureQuantity: No price found for feature ${newOptions.feature_id}`,
code: ErrCode.PriceNotFound,
});
}
let subItem = subToUpdate?.items.data.find(
(item: Stripe.SubscriptionItem) =>
item.price.id == config.stripe_price_id,
);
let subItem = findSubItemForPrice({
price,
subItems: subToUpdate.items.data,
});
if (!subItem) {
// Create new subscription item
subItem = await stripeCli.subscriptionItems.create({
subscription: subToUpdate.id,
price: config.stripe_price_id as string,
price: price.config.stripe_price_id as string,
quantity: newOptions.quantity,
proration_behavior: prorationBehavior,
payment_behavior: "error_if_incomplete",
});
console.log(
` Successfully created subscription item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
logger.info(
`updateFeatureQuantity: Successfully created sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
);
} else {
// Update quantity
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: newOptions.quantity,
proration_behavior: prorationBehavior,
payment_behavior: "error_if_incomplete",
});
console.log(
` Successfully updated subscription item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
logger.info(
`updateFeatureQuantity: Successfully updated sub item for feature ${newOptions.feature_id}: ${newOptions.quantity}`,
);
}
// Update cus ent
let difference = newOptions.quantity - oldOptions.quantity;
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 ==
@@ -102,14 +122,10 @@ export const updateFeatureQuantity = async ({
);
if (cusEnt) {
let updates: any = {
balance: new Decimal(cusEnt?.balance || 0).plus(difference).toNumber(),
};
await CusEntService.update({
await CusEntService.increment({
db,
id: cusEnt.id,
updates,
amount: difference.toNumber(),
});
}
}

View File

@@ -5,21 +5,23 @@ import {
} from "../../../cusProducts/AttachParams.js";
import { attachParamToCusProducts } from "../../attachUtils/convertAttachParams.js";
import { updateFeatureQuantity } from "./updateFeatureQuantity.js";
import { AttachConfig } from "@autumn/shared";
export const handleUpdateQuantityFunction = async ({
req,
res,
attachParams,
config,
}: {
req: any;
res: any;
attachParams: AttachParams;
config: AttachConfig;
}) => {
// 2. Update quantities
const optionsToUpdate = attachParams.optionsToUpdate!;
const { customer } = attachParams;
const { curSameProduct } = attachParamToCusProducts({ attachParams });
await updateFeatureQuantity({
@@ -27,12 +29,14 @@ export const handleUpdateQuantityFunction = async ({
stripeCli: attachParams.stripeCli,
cusProduct: curSameProduct!,
optionsToUpdate: optionsToUpdate!,
config,
logger: req.logtail,
});
res.status(200).json(
AttachResultSchema.parse({
customer_id: customer.id || customer.internal_id,
product_id: curSameProduct!.product.id,
product_ids: attachParams.products.map((p) => p.id),
code: SuccessCode.FeaturesUpdated,
message: `Successfully updated quantity for features: ${optionsToUpdate.map((o) => o.new.feature_id).join(", ")}`,
}),

View File

@@ -3,11 +3,11 @@ import {
AttachParams,
AttachResultSchema,
} from "../../../cusProducts/AttachParams.js";
import { handleStripeSubUpdate } from "@/internal/customers/change-product/handleUpgrade.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { getExistingCusProducts } from "@/internal/customers/cusProducts/cusProductUtils/getExistingCusProducts.js";
import { billForRemainingUsages } from "@/internal/customers/change-product/billRemainingUsages.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import {
APIVersion,
AttachScenario,
@@ -15,15 +15,12 @@ import {
ProcessorType,
SuccessCode,
} from "@autumn/shared";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import { insertInvoiceFromAttach } from "@/internal/invoices/invoiceUtils.js";
import { AttachConfig } from "../../models/AttachFlags.js";
import { AttachConfig, ProrationBehavior } from "../../models/AttachFlags.js";
import { updateStripeSubs } from "./updateStripeSubs.js";
import Stripe from "stripe";
export const handleUpgradeFunction = async ({
req,
@@ -55,7 +52,7 @@ export const handleUpgradeFunction = async ({
subIds: curCusProduct.subscription_ids,
});
logger.info("1. Updating current subscription in Stripe");
logger.info("1. Updating current subscriptions in Stripe");
let { newSubs } = await updateStripeSubs({
db: req.db,
curCusProduct,
@@ -72,11 +69,11 @@ export const handleUpgradeFunction = async ({
attachParams,
curCusProduct,
newSubs,
config,
logger,
billImmediately: config.proration === ProrationBehavior.Immediately,
});
logger.info("3. Expiring old cus product & other subs");
logger.info("3. Expiring old cus product");
await CusProductService.update({
db: req.db,
cusProductId: curCusProduct.id,
@@ -92,11 +89,6 @@ export const handleUpgradeFunction = async ({
},
});
// Cancel other subscriptions (since not updated)
for (const sub of stripeSubs.slice(1)) {
await stripeCli.subscriptions.cancel(sub.id);
}
// Insert new cus product
logger.info("4. Creating new cus product");
await createFullCusProduct({
@@ -111,6 +103,7 @@ export const handleUpgradeFunction = async ({
carryExistingUsages: carryUsage,
carryOverTrial: true,
scenario: AttachScenario.Upgrade,
logger,
});
// Insert invoices

View File

@@ -59,8 +59,14 @@ export const updateStripeSubs = async ({
let trialEnd = config.disableTrial
? null
: freeTrialToStripeTimestamp(attachParams.freeTrial);
: freeTrialToStripeTimestamp({
freeTrial: attachParams.freeTrial,
now: attachParams.now,
});
// console.log("Now:", formatUnixToDateTime(attachParams.now));
// console.log("Free trial:", attachParams.freeTrial);
// console.log("Trial end:", formatUnixToDateTime(trialEnd * 1000));
// 2. Update current subscription
let newSubs: Stripe.Subscription[] = [];
const subUpdateRes = await updateStripeSubscription({
@@ -97,8 +103,18 @@ export const updateStripeSubs = async ({
logger,
});
// OPTIMIZE GET STRIPE NOW?
// 4. Create subs for other intervals
// 4. Cancel other subscriptions
for (const sub of stripeSubs.slice(1)) {
logger.info(`1.4: canceling additional sub: ${sub.id}`);
await stripeCli.subscriptions.cancel(sub.id, {
prorate: true,
cancellation_details: {
comment: "autumn_upgrade",
},
});
}
// 5. Create subs for other intervals
const now = await getStripeNow({ stripeCli, stripeSub: subUpdate });
for (const itemSet of itemSets.slice(1)) {
const newSub = (await createStripeSub({

View File

@@ -1,15 +1,6 @@
import { Router } from "express";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { z } from "zod";
import {
APIVersion,
BillingType,
FeatureOptions,
FeatureOptionsSchema,
FullCusProduct,
ProductItem,
ProductItemSchema,
} from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { APIVersion, BillingType, FullCusProduct } from "@autumn/shared";
import { ErrCode } from "@/errors/errCodes.js";
import {
createStripeCusIfNotExists,
@@ -45,19 +36,13 @@ import { orgToVersion } from "@/utils/versionUtils.js";
import { handleExistingProduct } from "@/internal/customers/add-product/handleExistingProduct.js";
import { handleAddFreeProduct } from "@/internal/customers/add-product/handleAddFreeProduct.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import { handleChangeProduct } from "@/internal/customers/change-product/handleChangeProduct.js";
import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { AttachBodySchema } from "./models/AttachBody.js";
import { processAttachBody } from "./attachUtils/processAttachBody.js";
import { getAttachBranch } from "./attachUtils/getAttachBranch.js";
import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js";
import { runAttachFunction } from "./attachUtils/getAttachFunction.js";
import { getAttachConfig } from "./attachUtils/getAttachConfig.js";
import { insertCustomItems } from "./attachUtils/insertCustomItems.js";
import { handleAttachPreview } from "./handleAttachPreview/handleAttachPreview.js";
import { getAttachParams } from "./attachUtils/getAttachParams.js";
import { handleAttach } from "./handleAttach.js";
export const attachRouter = Router();
@@ -258,68 +243,6 @@ export const customerHasPm = async ({
return notNullOrUndefined(paymentMethod) ? true : false;
};
const handleAttachNew = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "attach",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
await handleAttachRaceCondition({ req, res });
const attachBody = AttachBodySchema.parse(req.body);
const logger = req.logtail;
const { attachParams, customPrices, customEnts } = await getAttachParams({
req,
attachBody,
});
// Handle existing product
const branch = await getAttachBranch({
req,
attachBody,
attachParams,
});
const { flags, config } = await getAttachConfig({
req,
attachParams,
attachBody,
branch,
});
await handleAttachErrors({
attachParams,
attachBody,
branch,
flags,
config,
});
await checkStripeConnections({ req, attachParams });
await createStripePrices({
attachParams,
useCheckout: config.onlyCheckout,
req,
logger,
});
await insertCustomItems({
db: req.db,
customPrices: customPrices || [],
customEnts: customEnts || [],
});
await runAttachFunction({
req,
res,
attachParams,
branch,
attachBody,
config,
});
},
});
const handleAttachOld = async (req: any, res: any) =>
routeHandler({
action: "attach",
@@ -508,17 +431,17 @@ const handleAttachOld = async (req: any, res: any) =>
return;
}
// SCENARIO 4: Switching product
if (curCusProduct) {
logger.info("SCENARIO 3: SWITCHING PRODUCT");
await handleChangeProduct({
req,
res,
attachParams,
curCusProduct,
});
return;
}
// // SCENARIO 4: Switching product
// if (curCusProduct) {
// logger.info("SCENARIO 3: SWITCHING PRODUCT");
// await handleChangeProduct({
// req,
// res,
// attachParams,
// curCusProduct,
// });
// return;
// }
// SCENARIO 5: No existing product, not free product
logger.info("SCENARIO 4: ADDING PRODUCT");
@@ -530,6 +453,6 @@ const handleAttachOld = async (req: any, res: any) =>
},
});
attachRouter.post("", handleAttachNew);
attachRouter.post("", handleAttach);
attachRouter.post("/preview", handleAttachPreview);
// attachRouter.post("", handleAttachOld);

View File

@@ -3,7 +3,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachBody } from "../models/AttachBody.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { notNullish } from "@/utils/genUtils.js";
import { AttachBranch } from "../models/AttachBranch.js";
import { AttachBranch } from "@autumn/shared";
import { getExistingCusProducts } from "../../cusProducts/cusProductUtils/getExistingCusProducts.js";
import { pricesOnlyOneOff } from "@/internal/products/prices/priceUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
@@ -21,6 +21,7 @@ import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { productsAreSame } from "@/internal/products/compareProductUtils.js";
import { StatusCodes } from "http-status-codes";
import { isTrialing } from "../../cusProducts/cusProductUtils.js";
import { hasPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils.js";
const checkMultiProductErrors = async ({
attachParams,
@@ -109,17 +110,18 @@ const checkSameCustom = async ({
}) => {
let product = attachParams.products[0];
let { itemsSame, freeTrialsSame, onlyEntsChanged } = productsAreSame({
v1Product1: {
...product,
prices: attachParams.prices,
entitlements: attachParams.entitlements,
free_trial: attachParams.freeTrial,
},
v1Product2: cusProductToProduct({ cusProduct: curSameProduct }),
let { itemsSame, freeTrialsSame, onlyEntsChanged, newItems } =
productsAreSame({
newProductV1: {
...product,
prices: attachParams.prices,
entitlements: attachParams.entitlements,
free_trial: attachParams.freeTrial,
},
curProductV1: cusProductToProduct({ cusProduct: curSameProduct }),
features: attachParams.features,
});
features: attachParams.features,
});
if (itemsSame && freeTrialsSame) {
throw new RecaseError({
@@ -137,8 +139,10 @@ const checkSameCustom = async ({
const getSameProductBranch = async ({
attachParams,
fromPreview,
}: {
attachParams: AttachParams;
fromPreview?: boolean;
}) => {
let product = attachParams.products[0];
@@ -199,6 +203,12 @@ const getSameProductBranch = async ({
return AttachBranch.Renew;
}
if (fromPreview) {
if (hasPrepaidPrice({ prices: attachParams.prices })) {
return AttachBranch.UpdatePrepaidQuantity;
}
}
// Invalid, can't attach same product
throw new RecaseError({
message: `Product ${product.name} is already attached, can't attach again`,
@@ -243,10 +253,12 @@ export const getAttachBranch = async ({
req,
attachBody,
attachParams,
fromPreview,
}: {
req: ExtendedRequest;
attachBody: AttachBody;
attachParams: AttachParams;
fromPreview?: boolean;
}) => {
// 1. Multi product
if (notNullish(attachBody.product_ids)) {
@@ -266,7 +278,12 @@ export const getAttachBranch = async ({
// 3. Same product
if (curSameProduct) {
return await getSameProductBranch({ attachParams });
return await getSameProductBranch({ attachParams, fromPreview });
}
let product = attachParams.products[0];
if (product.is_add_on) {
return AttachBranch.AddOn;
}
// 4. Main product exists
@@ -274,12 +291,5 @@ export const getAttachBranch = async ({
return getChangeProductBranch({ attachParams });
}
// 5. New product!
let product = attachParams.products[0];
if (product.is_add_on) {
return AttachBranch.AddOn;
} else {
return AttachBranch.New;
}
return AttachBranch.New;
};

View File

@@ -1,11 +1,12 @@
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachConfig, AttachFlags } from "../models/AttachFlags.js";
import { AttachBranch } from "../models/AttachBranch.js";
import { AttachBranch } from "@autumn/shared";
import { AttachBody } from "../models/AttachBody.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { ProrationBehavior } from "../../change-product/handleUpgrade.js";
import { AppEnv } from "@autumn/shared";
import { Organization } from "@autumn/shared";
export const getAttachConfig = async ({
req,
@@ -40,8 +41,22 @@ export const getAttachConfig = async ({
? ProrationBehavior.Immediately
: ProrationBehavior.NextBilling,
disableTrial:
branch === AttachBranch.NewVersion || attachBody.free_trial === false,
branch === AttachBranch.NewVersion ||
branch == AttachBranch.Downgrade ||
attachBody.free_trial === false,
};
return { flags, config };
};
const webhookToConfig = ({ org, env }: { org: Organization; env: AppEnv }) => {
const config: AttachConfig = {
branch: AttachBranch.NewVersion, // not needed...
carryUsage: false, // not needed...
onlyCheckout: false,
proration: ProrationBehavior.Immediately,
disableTrial: false,
};
return config;
};

View File

@@ -3,20 +3,19 @@ import {
AttachParams,
AttachResultSchema,
} from "../../cusProducts/AttachParams.js";
import { AttachBranch, AttachFunction } from "../models/AttachBranch.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { nullish } from "@/utils/genUtils.js";
import { handleUpgrade } from "../../change-product/handleUpgrade.js";
import { AttachBranch, AttachFunction } from "@autumn/shared";
import { handleUpgradeFunction } from "../attachFunctions/upgradeFlow/handleUpgradeFunction.js";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { handleAddProduct } from "../../add-product/handleAddProduct.js";
import { AttachBody } from "../models/AttachBody.js";
import { AttachConfig, AttachFlags } from "../models/AttachFlags.js";
import { AttachConfig } from "../models/AttachFlags.js";
import { handleScheduleFunction } from "../attachFunctions/scheduleFlow/handleScheduleFunction.js";
import { cancelFutureProductSchedule } from "../../change-product/scheduleUtils.js";
import { handleEntsChangedFunction } from "../attachFunctions/updateEntsFlow/handleEntsChangedFunction.js";
import { handleUpdateQuantityFunction } from "../attachFunctions/updateQuantityFlow/updateQuantityFlow.js";
import { SuccessCode } from "@autumn/shared";
import { attachParamToCusProducts } from "./convertAttachParams.js";
import chalk from "chalk";
/*
1. If from new version, free trial should just carry over
@@ -130,10 +129,23 @@ export const runAttachFunction = async ({
const customer = attachParams.customer;
const org = attachParams.org;
const productIdsStr = attachParams.products.map((p) => p.id).join(", ");
const { curMainProduct, curSameProduct, curScheduledProduct } =
attachParamToCusProducts({
attachParams,
});
logger.info(`--------------------------------`);
logger.info(
`ATTACHING ${productIdsStr} to ${customer.name} (${customer.id || customer.email}), org: ${org.slug}`,
);
logger.info(`Branch: ${branch}, Function: ${attachFunction}`);
logger.info(
`Branch: ${chalk.yellow(branch)}, Function: ${chalk.yellow(attachFunction)}`,
{
curMainProduct: curMainProduct?.product.id,
curSameProduct: curSameProduct?.product.id,
curScheduledProduct: curScheduledProduct?.product.id,
},
);
// 1. Cancel future schedule before creating a new one...
await cancelFutureProductSchedule({
@@ -208,6 +220,7 @@ export const runAttachFunction = async ({
req,
res,
attachParams,
config,
});
}
};

View File

@@ -4,8 +4,12 @@ import { processAttachBody } from "./processAttachBody.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { APIVersion } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import {
getCusPaymentMethod,
getStripeCus,
} from "@/external/stripe/stripeCusUtils.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
export const getAttachParams = async ({
req,
@@ -45,13 +49,32 @@ export const getAttachParams = async ({
: undefined;
const stripeCli = createStripeCli({ org, env });
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: customer.processor?.id,
});
const [paymentMethod, { stripeCus, now }] = await Promise.all([
getCusPaymentMethod({
stripeCli,
stripeId: customer.processor?.id,
}),
(async () => {
try {
const stripeCus = await getStripeCus({
stripeCli,
stripeId: customer.processor?.id,
});
const now = await getStripeNow({
stripeCli,
stripeCus,
});
return { stripeCus, now };
} catch (error) {
return { stripeCus: undefined, now: undefined };
}
})(),
]);
const attachParams: AttachParams = {
stripeCli,
stripeCus,
now,
paymentMethod,
customer,

View File

@@ -2,7 +2,7 @@ import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import { StatusCodes } from "http-status-codes";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { AttachBranch } from "../models/AttachBranch.js";
import { AttachBranch } from "@autumn/shared";
import { AttachBody } from "../models/AttachBody.js";
import { AttachConfig, AttachFlags } from "../models/AttachFlags.js";
import {

View File

@@ -25,6 +25,10 @@ import {
} from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { handleNewProductItems } from "@/internal/products/product-items/productItemInitUtils.js";
import { getEntsWithFeature } from "@/internal/products/entitlements/entitlementUtils.js";
import {
isMainProduct,
oneOffOrAddOn,
} from "@/internal/products/productUtils/classifyProduct.js";
const getProductsForAttach = async ({
req,
@@ -132,13 +136,15 @@ const getPricesAndEnts = async ({
});
}
const prodIsMain = isMainProduct({ product: products[0], prices });
return {
optionsList: mapOptionsList({
optionsInput,
features,
prices,
// to check if it fails for multi prod attach...
curCusProduct: products[0].is_add_on ? curSameProduct : curMainProduct,
curCusProduct: prodIsMain ? curMainProduct : curSameProduct,
}),
prices,
entitlements,
@@ -189,12 +195,14 @@ const getPricesAndEnts = async ({
multipleAllowed: org.config.multiple_trials,
});
const prodIsMain = isMainProduct({ product: products[0], prices });
return {
optionsList: mapOptionsList({
optionsInput,
features,
prices,
curCusProduct: product.is_add_on ? curSameProduct : curMainProduct,
curCusProduct: prodIsMain ? curMainProduct : curSameProduct,
}),
prices,
entitlements: getEntsWithFeature({

View File

@@ -0,0 +1,73 @@
import { handleAttachRaceCondition } from "@/external/redis/redisUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { AttachBodySchema } from "./models/AttachBody.js";
import { getAttachParams } from "./attachUtils/getAttachParams.js";
import { getAttachBranch } from "./attachUtils/getAttachBranch.js";
import { getAttachConfig } from "./attachUtils/getAttachConfig.js";
import { handleAttachErrors } from "./attachUtils/handleAttachErrors.js";
import { checkStripeConnections, createStripePrices } from "./attachRouter.js";
import { insertCustomItems } from "./attachUtils/insertCustomItems.js";
import { runAttachFunction } from "./attachUtils/getAttachFunction.js";
export const handleAttach = async (req: any, res: any) =>
routeHandler({
req,
res,
action: "attach",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
await handleAttachRaceCondition({ req, res });
const attachBody = AttachBodySchema.parse(req.body);
const logger = req.logtail;
const { attachParams, customPrices, customEnts } = await getAttachParams({
req,
attachBody,
});
// Handle existing product
const branch = await getAttachBranch({
req,
attachBody,
attachParams,
});
const { flags, config } = await getAttachConfig({
req,
attachParams,
attachBody,
branch,
});
await handleAttachErrors({
attachParams,
attachBody,
branch,
flags,
config,
});
await checkStripeConnections({ req, attachParams });
await createStripePrices({
attachParams,
useCheckout: config.onlyCheckout,
req,
logger,
});
await insertCustomItems({
db: req.db,
customPrices: customPrices || [],
customEnts: customEnts || [],
});
await runAttachFunction({
req,
res,
attachParams,
branch,
attachBody,
config,
});
},
});

View File

@@ -0,0 +1,57 @@
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { UsageModel } from "@autumn/shared";
export const getDowngradeProductPreview = async ({
attachParams,
now,
}: {
attachParams: AttachParams;
now: number;
}) => {
const newProduct = attachParamsToProduct({ attachParams });
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const stripeSubs = await getStripeSubs({
stripeCli: attachParams.stripeCli,
subIds: curMainProduct?.subscription_ids || [],
});
const anchorToUnix = stripeSubs[0].current_period_end * 1000;
let items = getItemsForNewProduct({
newProduct,
attachParams,
now,
// anchorToUnix,
});
items = items.filter((item) => item.usage_model !== UsageModel.Prepaid);
let options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
features: attachParams.features,
}),
features: attachParams.features,
// anchorToUnix,
});
return {
currency: attachParams.org.default_currency,
due_next_cycle: {
line_items: items,
due_at: anchorToUnix,
},
options,
};
};

View File

@@ -1,81 +1,54 @@
import { Feature, FullProduct, Organization, UsageModel } from "@autumn/shared";
import { mapToProductV2 } from "@/internal/products/productV2Utils.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import {
isFeatureItem,
isPriceItem,
} from "@/internal/products/product-items/getItemType.js";
import {
getPricecnPrice,
sortProductItems,
} from "@/internal/products/pricecn/pricecnUtils.js";
import { BillingInterval } from "@autumn/shared";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { AttachScenario } from "@autumn/shared";
import { getItemDescription } from "../../previews/checkProductUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
export const getNewProductPreview = async ({
org,
product,
features,
attachParams,
now,
}: {
org: Organization;
product: FullProduct;
features: Feature[];
attachParams: AttachParams;
now: number;
}) => {
let productV2 = mapToProductV2({
product,
features,
const { org } = attachParams;
const newProduct = attachParamsToProduct({ attachParams });
let anchorToUnix = undefined;
if (org.config.anchor_start_of_month) {
anchorToUnix = getNextStartOfMonthUnix(BillingInterval.Month);
}
const items = getItemsForNewProduct({
newProduct,
attachParams,
now,
anchorToUnix,
});
let sortedItems = sortProductItems(productV2.items, features);
let lineItems = sortedItems
.filter((i) => !isFeatureItem(i) && i.usage_model != UsageModel.Prepaid)
.map((i, index) => {
let pricecnPrice = getPricecnPrice({
org,
items: [i],
features,
isMainPrice: index == 0,
});
let description = getItemDescription({
item: i,
features,
product: productV2,
org,
});
return {
description,
price: `${pricecnPrice.primaryText} ${pricecnPrice.secondaryText}`,
};
});
let dueToday = Number(
sortedItems
.filter((i) => isPriceItem(i))
.reduce((sum, i) => sum + i.price!, 0)
.toFixed(2),
);
const dueTodayAmt = items.reduce((acc, item) => {
return acc + (item.amount ?? 0);
}, 0);
let options = getOptions({
prodItems: productV2.items,
features,
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
features: attachParams.features,
}),
features: attachParams.features,
anchorToUnix,
});
return {
scenario: AttachScenario.New,
product_id: product.id,
product_name: product.name,
recurring: !isOneOff(product.prices),
items: lineItems,
options,
currency: attachParams.org.default_currency,
due_today: {
price: dueToday,
currency: org.default_currency || "USD",
line_items: items,
total: dueTodayAmt,
},
options,
};
};

View File

@@ -0,0 +1,31 @@
import { productsAreSame } from "@/internal/products/compareProductUtils.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
export const getUpdateEntsPreview = async ({
req,
attachParams,
now,
}: {
req: any;
attachParams: any;
now: number;
}) => {
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const curProduct = cusProductToProduct({ cusProduct: curMainProduct! });
const newProduct = attachParamsToProduct({ attachParams });
const features = attachParams.features;
const res = productsAreSame({
newProductV1: newProduct,
curProductV1: curProduct,
features,
});
return {
new_items: res.newItems,
};
};

View File

@@ -1,52 +1,92 @@
import {
BillingType,
Feature,
FullProduct,
Organization,
} from "@autumn/shared";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { CusProductService } from "../../cusProducts/CusProductService.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { getNextCycle } from "../attachFunctions/upgradeFlow/upgradeUtils.js";
import Stripe from "stripe";
import { findPriceInStripeItems } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { cusProductToPrices } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
import { formatUnixToDateTime } from "@/utils/genUtils.js";
import { Decimal } from "decimal.js";
import { calculateProrationAmount } from "@/internal/invoices/prorationUtils.js";
import {
newPriceToInvoiceDescription,
priceToInvoiceDescription,
} from "@/internal/invoices/invoiceFormatUtils.js";
import {
getBillingType,
getPriceEntitlement,
getPriceForOverage,
} from "@/internal/products/prices/priceUtils.js";
import { getCusPriceUsage } from "../../cusProducts/cusPrices/cusPriceUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { getSubItemAmount } from "@/external/stripe/stripeSubUtils/getSubItemAmount.js";
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { getExistingUsageFromCusProducts } from "../../cusProducts/cusEnts/cusEntUtils.js";
import {
isFixedPrice,
isUsagePrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils.js";
import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
getFirstInterval,
getLastInterval,
} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import Stripe from "stripe";
import {
AttachBranch,
BillingInterval,
FreeTrial,
Price,
UsageModel,
} from "@autumn/shared";
import {
addBillingIntervalUnix,
getAlignedIntervalUnix,
} from "@/internal/products/prices/billingIntervalUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.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 getUpgradeProductPreview = async ({
req,
attachParams,
branch,
now,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
branch: AttachBranch;
now: number;
}) => {
const { logtail: logger } = req;
@@ -54,108 +94,90 @@ export const getUpgradeProductPreview = async ({
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = curMainProduct!;
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
const stripeSubs = await getStripeSubs({
stripeCli,
subIds: curCusProduct.subscription_ids || [],
subIds: curCusProduct?.subscription_ids || [],
expand: ["items.data.price.tiers"],
});
const now = await getStripeNow({ stripeCli, stripeSub: stripeSubs[0] });
// Get prorated refunds for old product
for (const sub of stripeSubs) {
for (const item of sub.items.data) {
const price = findPriceInStripeItems({
prices: curPrices,
subItem: item,
});
if (!price) continue;
const billingType = getBillingType(price.config);
if (billingType == BillingType.UsageInArrear) continue;
const totalAmount = getSubItemAmount({ subItem: item });
const periodEnd = sub.current_period_end * 1000;
const periodStart = sub.current_period_start * 1000;
if (now < periodEnd) {
const proratedAmount = calculateProrationAmount({
periodEnd,
periodStart,
now,
amount: totalAmount,
});
const description = priceToInvoiceDescription({
price,
cusProduct: curCusProduct,
quantity: item.quantity,
logger,
});
console.log("Item:", description);
console.log("Period ends:", formatUnixToDateTime(periodEnd));
console.log("Prorated amount: ", proratedAmount);
console.log("--------------------------------");
}
}
}
let curPreviewItems = getItemsForCurProduct({
stripeSubs,
attachParams,
now,
logger,
});
// Get prorated amounts for new product
const newProduct = attachParamsToProduct({ attachParams });
// Check if new product will have cycle reset
const firstInterval = getFirstInterval({ prices: newProduct.prices });
const prevInterval = subToAutumnInterval(stripeSubs[0]);
const cycleWillReset = prevInterval !== firstInterval;
console.log("Cycle will reset: ", cycleWillReset);
const newPreviewItems = getItemsForNewProduct({
newProduct,
attachParams,
now,
anchorToUnix: !cycleWillReset
? stripeSubs[0].current_period_end * 1000
: undefined,
freeTrial: attachParams.freeTrial,
}).filter((item) => notNullish(item.amount) && item.amount != 0);
for (const price of newProduct.prices) {
const ent = getPriceEntitlement(price, newProduct.entitlements);
const billingType = getBillingType(price.config);
const lastInterval = getLastInterval({ prices: newProduct.prices });
const nextCycleAt = getNextCycleAt({
prices: newProduct.prices,
stripeSubs,
willCycleReset: cycleWillReset,
interval: lastInterval,
now,
freeTrial: attachParams.freeTrial,
});
if (
billingType == BillingType.UsageInArrear ||
billingType == BillingType.UsageInAdvance
) {
continue;
}
let nextCycleItems = getItemsForNewProduct({
newProduct,
attachParams,
interval: attachParams.freeTrial ? undefined : lastInterval,
});
let amount, usage;
if (isFixedPrice({ price })) {
amount = getPriceForOverage(price);
} else {
usage = getExistingUsageFromCusProducts({
entitlement: ent,
cusProducts: attachParams.cusProducts,
entities: attachParams.entities,
carryExistingUsages: undefined,
internalEntityId: attachParams.internalEntityId,
});
let items = [...curPreviewItems, ...newPreviewItems];
const overage = new Decimal(usage).sub(ent.allowance!).toNumber();
amount = getPriceForOverage(price, overage);
}
const dueTodayAmt = items.reduce((acc, item) => {
return acc + (item.amount ?? 0);
}, 0);
const description = newPriceToInvoiceDescription({
price,
product: newProduct,
quantity: usage,
});
let options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
features: attachParams.features,
}),
features: attachParams.features,
anchorToUnix: cycleWillReset
? undefined
: stripeSubs[0].current_period_end * 1000,
now,
freeTrial: attachParams.freeTrial,
});
const proratedAmount = calculateProrationAmount({
periodEnd: stripeSubs[0].current_period_end * 1000,
periodStart: now,
now,
amount,
});
console.log("Item:", description);
console.log("Amount: ", amount);
console.log("--------------------------------");
if (branch == AttachBranch.UpdatePrepaidQuantity) {
items = items.filter((item) => item.usage_model == UsageModel.Prepaid);
nextCycleItems = nextCycleItems.filter(
(item) => item.usage_model == UsageModel.Prepaid,
);
}
return {
currency: attachParams.org.default_currency,
due_today: {
line_items: items,
total: dueTodayAmt,
},
due_next_cycle: {
line_items: nextCycleItems,
due_at: nextCycleAt.next_cycle_at,
},
options,
};
};

View File

@@ -8,9 +8,12 @@ import { ExtendedResponse } from "@/utils/models/Request.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { AttachFunction } from "../models/AttachBranch.js";
import { getNewProductPreview } from "./getNewProductPreview.js";
import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { CheckProductPreview } from "@autumn/shared";
import { getUpgradeProductPreview } from "./getUpgradeProductPreview.js";
import { getStripeNow } from "@/utils/scriptUtils/testClockUtils.js";
import { getUpdateEntsPreview } from "./getUpdateEntsPreview.js";
import { getDowngradeProductPreview } from "./getDowngradeProductPreview.js";
import { attachParamToCusProducts } from "../attachUtils/convertAttachParams.js";
import { cusProductToProduct } from "../../cusProducts/cusProductUtils/convertCusProduct.js";
export const handleAttachPreview = (req: any, res: any) =>
routeHandler({
@@ -30,6 +33,7 @@ export const handleAttachPreview = (req: any, res: any) =>
req,
attachBody,
attachParams,
fromPreview: true,
});
const { flags, config } = await getAttachConfig({
@@ -48,31 +52,57 @@ export const handleAttachPreview = (req: any, res: any) =>
console.log(`Branch: ${branch}, Function: ${func}`);
const { org, features } = attachParams;
const product = attachParamsToProduct({
attachParams,
});
const { stripeCli, stripeCus } = attachParams;
const now = await getStripeNow({ stripeCli, stripeCus });
let preview: any = null;
if (func == AttachFunction.UpdateEnts) {
preview = await getUpdateEntsPreview({
req,
attachParams,
now,
});
}
if (
func == AttachFunction.AddProduct ||
func == AttachFunction.CreateCheckout
) {
preview = await getNewProductPreview({
org,
product,
features,
attachParams,
now,
});
}
if (func == AttachFunction.UpdateProduct) {
if (func == AttachFunction.ScheduleProduct) {
preview = await getDowngradeProductPreview({
attachParams,
now,
});
}
if (
func == AttachFunction.UpdateProduct ||
func == AttachFunction.UpdatePrepaidQuantity
) {
preview = await getUpgradeProductPreview({
req,
attachParams,
branch,
now,
});
}
res.status(200).json({ preview });
const { curMainProduct } = attachParamToCusProducts({ attachParams });
res.status(200).json({
branch,
...preview,
current_product: curMainProduct
? cusProductToProduct({
cusProduct: curMainProduct,
})
: null,
});
},
});

View File

@@ -1,23 +1,24 @@
export enum AttachBranch {
MultiProduct = "multi_product",
OneOff = "one_off",
New = "new",
AddOn = "add_on",
// export enum AttachBranch {
// MultiProduct = "multi_product",
// Same product
NewVersion = "new_version", //
SameCustomEnts = "same_custom_ents",
SameCustom = "same_custom",
// OneOff = "one_off",
UpdatePrepaidQuantity = "update_prepaid_quantity",
Renew = "renew",
// New = "new",
// AddOn = "add_on",
// Handle upgrades / downgrades
MainIsFree = "main_is_free",
MainIsTrial = "main_is_trial",
Upgrade = "upgrade",
Downgrade = "downgrade",
}
// // Same product
// NewVersion = "new_version",
// SameCustomEnts = "same_custom_ents",
// SameCustom = "same_custom",
// UpdatePrepaidQuantity = "update_prepaid_quantity",
// Renew = "renew",
// // Handle upgrades / downgrades
// MainIsFree = "main_is_free",
// MainIsTrial = "main_is_trial",
// Upgrade = "upgrade",
// Downgrade = "downgrade",
// }
export enum AttachFunction {
CreateCheckout = "create_checkout",

View File

@@ -1,4 +1,4 @@
import { AttachBranch } from "./AttachBranch.js";
import { AttachBranch } from "@autumn/shared";
export enum ProrationBehavior {
Immediately = "immediately",

View File

@@ -37,7 +37,6 @@ const addUsageToNextInvoice = async ({
org,
logger,
attachParams,
curCusProduct,
}: {
db: DrizzleCli;
intervalToInvoiceItems: any;
@@ -45,9 +44,7 @@ const addUsageToNextInvoice = async ({
customer: any;
org: any;
logger: any;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
}) => {
for (const interval in intervalToInvoiceItems) {
const itemsToInvoice = intervalToInvoiceItems[interval];
@@ -63,7 +60,7 @@ const addUsageToNextInvoice = async ({
});
for (const item of itemsToInvoice) {
const amount = getPriceForOverage(item.price, item.overage);
const { amount, description } = item;
logger.info(
` feature: ${item.feature.id}, overage: ${item.overage}, amount: ${amount}`,
@@ -71,11 +68,6 @@ const addUsageToNextInvoice = async ({
let relatedSub = intervalToSub[interval];
if (!relatedSub) {
logger.error(
`No sub found for interval: ${interval}, for feature: ${item.feature.id}`,
);
// Invoice immediately?
continue;
}
@@ -83,9 +75,7 @@ const addUsageToNextInvoice = async ({
let invoiceItem = {
customer: customer.processor.id,
currency: org.default_currency,
description: `${curCusProduct.product.name} - ${
item.feature.name
} x ${Math.round(item.usage)}`,
description,
price_data: {
product: (item.price.config! as UsagePriceConfig).stripe_product_id!,
unit_amount: Math.round(amount * 100),
@@ -190,14 +180,10 @@ const invoiceForUsageImmediately = async ({
let autumnInvoiceItems: InvoiceItem[] = [];
for (const item of invoiceItems) {
const amount = getPriceForOverage(item.price, item.overage);
// const amount = getPriceForOverage(item.price, item.overage);
const { amount, description } = item;
let config = item.price.config! as UsagePriceConfig;
// TO TEST
let stripePrice = await stripeCli.prices.retrieve(config.stripe_price_id!);
let description = `${curCusProduct.product.name} - ${
item.feature.name
} x ${Math.round(item.usage)}`; // need to standardize...
logger.info(
`🌟🌟🌟 (Bill remaining) created invoice item: ${description} -- ${amount}`,
@@ -207,7 +193,7 @@ const invoiceForUsageImmediately = async ({
customer: customer.processor.id,
invoice: invoice.id,
currency: org.default_currency,
description: description,
description,
price_data: {
product: stripePrice.product as string,
unit_amount: Math.round(amount * 100),
@@ -308,16 +294,16 @@ export const billForRemainingUsages = async ({
attachParams,
curCusProduct,
newSubs,
config,
shouldPreview = false,
billImmediately = false,
}: {
db: DrizzleCli;
logger: any;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
newSubs: Stripe.Subscription[];
config: AttachConfig;
shouldPreview?: boolean;
billImmediately?: boolean;
}) => {
const { customer_prices, customer_entitlements } = curCusProduct;
const { customer, org } = attachParams;
@@ -346,7 +332,8 @@ export const billForRemainingUsages = async ({
const billingType = getBillingType(config);
if (billingType !== BillingType.UsageInArrear) continue;
const { usage, overage, roundedUsage } = getCusPriceUsage({
const { usage, overage, description, amount } = getCusPriceUsage({
cusPrice: cp,
cusProduct: curCusProduct,
logger,
@@ -369,6 +356,9 @@ export const billForRemainingUsages = async ({
intervalToInvoiceItems[interval].push({
overage,
usage,
description,
amount,
feature: relatedCusEnt?.entitlement.feature,
price: cp.price,
relatedCusEnt,
@@ -384,7 +374,7 @@ export const billForRemainingUsages = async ({
});
}
if (config.proration == ProrationBehavior.Immediately) {
if (billImmediately) {
await invoiceForUsageImmediately({
db,
intervalToInvoiceItems,
@@ -404,7 +394,6 @@ export const billForRemainingUsages = async ({
org,
logger,
attachParams,
curCusProduct,
});
}
};

View File

@@ -1,90 +1,89 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { isProductUpgrade } from "@/internal/products/productUtils.js";
import { ErrCode, FullCusProduct } from "@autumn/shared";
import { AttachParams } from "../cusProducts/AttachParams.js";
import { handleUpgrade } from "./handleUpgrade.js";
import { StatusCodes } from "http-status-codes";
import { handleDowngrade } from "./handleDowngrade.js";
import { getPricesForCusProduct } from "./scheduleUtils.js";
import { cancelScheduledProductIfExists } from "./changeProductUtils.js";
import RecaseError from "@/utils/errorUtils.js";
// import { createStripeCli } from "@/external/stripe/utils.js";
// import { ProductService } from "@/internal/products/ProductService.js";
// import { isProductUpgrade } from "@/internal/products/productUtils.js";
// import { ErrCode, FullCusProduct } from "@autumn/shared";
// import { AttachParams } from "../cusProducts/AttachParams.js";
// import { handleUpgrade } from "./handleUpgrade.js";
// import { StatusCodes } from "http-status-codes";
// import { getPricesForCusProduct } from "./scheduleUtils.js";
// import { cancelScheduledProductIfExists } from "./changeProductUtils.js";
// import RecaseError from "@/utils/errorUtils.js";
export const handleChangeProduct = async ({
req,
res,
attachParams,
curCusProduct,
}: {
req: any;
res: any;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
}) => {
// Get subscription
const curProduct = curCusProduct.product;
const { org, customer, products } = attachParams;
// export const handleChangeProduct = async ({
// req,
// res,
// attachParams,
// curCusProduct,
// }: {
// req: any;
// res: any;
// attachParams: AttachParams;
// curCusProduct: FullCusProduct;
// }) => {
// // Get subscription
// const curProduct = curCusProduct.product;
// const { org, customer, products } = attachParams;
// Can only upgrade once for now
if (products.length > 1) {
throw new RecaseError({
message: `Can't handle upgrade / downgrade for multiple products`,
code: ErrCode.UpgradeFailed,
statusCode: StatusCodes.NOT_IMPLEMENTED,
});
}
// // Can only upgrade once for now
// if (products.length > 1) {
// throw new RecaseError({
// message: `Can't handle upgrade / downgrade for multiple products`,
// code: ErrCode.UpgradeFailed,
// statusCode: StatusCodes.NOT_IMPLEMENTED,
// });
// }
const stripeCli = createStripeCli({
org: attachParams.org,
env: attachParams.customer.env,
});
// const stripeCli = createStripeCli({
// org: attachParams.org,
// env: attachParams.customer.env,
// });
const logger = req.logtail;
// const logger = req.logtail;
// 0. Cancel any scheduled products
await cancelScheduledProductIfExists({
req,
org: attachParams.org,
stripeCli,
attachParams,
curFullProduct: curCusProduct.product as any,
logger,
});
// // 0. Cancel any scheduled products
// await cancelScheduledProductIfExists({
// req,
// org: attachParams.org,
// stripeCli,
// attachParams,
// curFullProduct: curCusProduct.product as any,
// logger,
// });
const curFullProduct = await ProductService.getFull({
db: req.db,
idOrInternalId: curProduct.id,
orgId: org.id,
env: customer.env,
});
// const curFullProduct = await ProductService.getFull({
// db: req.db,
// idOrInternalId: curProduct.id,
// orgId: org.id,
// env: customer.env,
// });
let curPrices = getPricesForCusProduct({
cusProduct: curCusProduct!,
});
let newPrices = attachParams.prices;
// let curPrices = getPricesForCusProduct({
// cusProduct: curCusProduct!,
// });
// let newPrices = attachParams.prices;
const isUpgrade =
attachParams.invoiceOnly ||
isProductUpgrade({
prices1: curPrices,
prices2: newPrices,
});
// const isUpgrade =
// attachParams.invoiceOnly ||
// isProductUpgrade({
// prices1: curPrices,
// prices2: newPrices,
// });
if (!isUpgrade) {
await handleDowngrade({
req,
res,
attachParams,
curCusProduct,
});
return;
} else {
await handleUpgrade({
req,
res,
attachParams,
curCusProduct,
curFullProduct,
});
}
};
// if (!isUpgrade) {
// await handleDowngrade({
// req,
// res,
// attachParams,
// curCusProduct,
// });
// return;
// } else {
// await handleUpgrade({
// req,
// res,
// attachParams,
// curCusProduct,
// curFullProduct,
// });
// }
// };

View File

@@ -1,283 +0,0 @@
import { CusProductService } from "../cusProducts/CusProductService.js";
import Stripe from "stripe";
import {
AttachParams,
AttachResultSchema,
} from "../cusProducts/AttachParams.js";
import {
getStripeSchedules,
getStripeSubs,
} from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { APIVersion, AttachScenario, FullCusProduct } from "@autumn/shared";
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
import { getCusPaymentMethod } from "@/external/stripe/stripeCusUtils.js";
import { BillingInterval } from "@autumn/shared";
import { updateScheduledSubWithNewItems } from "./scheduleUtils/updateScheduleWithNewItems.js";
import { createFullCusProduct } from "../add-product/createFullCusProduct.js";
import {
attachToInsertParams,
isFreeProduct,
} from "@/internal/products/productUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { SuccessCode } from "@autumn/shared";
import { cancelCurSubs } from "./handleDowngrade/cancelCurSubs.js";
import { getScheduleIdsFromCusProducts } from "./scheduleUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
const scheduleStripeSubscription = async ({
db,
attachParams,
stripeCli,
itemSet,
endOfBillingPeriod,
}: {
db: DrizzleCli;
attachParams: AttachParams;
stripeCli: Stripe;
itemSet: ItemSet;
endOfBillingPeriod: number;
}) => {
const { org, customer } = attachParams;
const { items, prices, subMeta } = itemSet;
const paymentMethod = await getCusPaymentMethod({
stripeCli,
stripeId: customer.processor.id,
});
let subItems = items.filter(
(item: any, index: number) =>
index >= prices.length ||
prices[index].config!.interval !== BillingInterval.OneOff,
);
let oneOffItems = items.filter(
(item: any, index: number) =>
index < prices.length &&
prices[index].config!.interval === BillingInterval.OneOff,
);
const newSubscriptionSchedule = await stripeCli.subscriptionSchedules.create({
customer: customer.processor.id,
start_date: endOfBillingPeriod,
phases: [
{
items: subItems,
default_payment_method: paymentMethod?.id,
add_invoice_items: oneOffItems,
},
],
});
await SubService.createSub({
db,
sub: {
id: generateId("sub"),
stripe_id: null,
stripe_schedule_id: newSubscriptionSchedule.id,
created_at: Date.now(),
usage_features: itemSet.usageFeatures,
org_id: org.id,
env: customer.env,
current_period_start: null,
current_period_end: null,
},
});
return newSubscriptionSchedule.id;
};
export const getCusProductsWithStripeSubIds = async ({
cusProducts,
stripeSubId,
curCusProductId,
}: {
cusProducts: FullCusProduct[];
stripeSubId: string;
curCusProductId?: string;
}) => {
return cusProducts.filter(
(cusProduct) =>
cusProduct.subscription_ids?.includes(stripeSubId) &&
cusProduct.id !== curCusProductId,
);
};
export const handleDowngrade = async ({
req,
res,
attachParams,
curCusProduct,
}: {
req: any;
res: any;
attachParams: AttachParams;
curCusProduct: FullCusProduct;
}) => {
const logger = req.logtail;
let product = attachParams.products[0];
const stripeCli = createStripeCli({
org: attachParams.org,
env: attachParams.customer.env,
});
logger.info(
`Handling downgrade from ${curCusProduct.product.name} to ${product.name}`,
);
const curSubscriptions = await getStripeSubs({
stripeCli,
subIds: curCusProduct.subscription_ids!,
});
const latestPeriodEnd = curSubscriptions[0].current_period_end;
// 1. Cancel all current subscriptions
logger.info("1. Cancelling current subscription (at period end)");
const intervalToOtherSubs = await cancelCurSubs({
curSubs: curSubscriptions,
stripeCli,
curCusProduct,
});
// 3. Schedule new subscription IF new product is not free...
logger.info("2. Schedule new subscription");
let oldScheduledIds: string[] = getScheduleIdsFromCusProducts({
cusProducts: [curCusProduct, attachParams.curScheduledProduct],
});
let schedules: any[] = [];
if (oldScheduledIds.length > 0) {
schedules = await getStripeSchedules({
stripeCli,
scheduleIds: oldScheduledIds,
});
}
// 4. Create new subscription schedule
const itemSets: any[] = await getStripeSubItems({
attachParams,
isCheckout: false,
});
let scheduledIds: string[] = [];
for (const itemSet of itemSets) {
let scheduleObj = schedules.find(
(schedule) => schedule.interval === itemSet.interval,
);
if (scheduleObj) {
await updateScheduledSubWithNewItems({
scheduleObj,
newItems: itemSet.items,
stripeCli,
cusProducts: [curCusProduct, attachParams.curScheduledProduct],
itemSet: itemSet,
db: req.db,
org: attachParams.org,
env: attachParams.customer.env,
});
scheduledIds.push(scheduleObj.schedule.id);
} else {
const otherSubObj = intervalToOtherSubs[itemSet.interval];
let otherSub = otherSubObj?.otherSub || null;
let otherSubItems = otherSubObj?.otherSubItems || [];
let otherCusProducts = otherSub
? await getCusProductsWithStripeSubIds({
cusProducts: attachParams.cusProducts!,
stripeSubId: otherSub.id,
})
: [];
// If there is other sub items
itemSet.items.push(
...otherSubItems.map((sub: any) => ({
price: sub.price.id,
quantity: sub.quantity,
})),
);
let scheduleId = await scheduleStripeSubscription({
db: req.db,
attachParams,
stripeCli,
itemSet,
endOfBillingPeriod: latestPeriodEnd,
});
scheduledIds.push(scheduleId);
if (otherCusProducts.length > 0) {
for (const otherCusProduct of otherCusProducts) {
let newScheduledIds = [
...(otherCusProduct.scheduled_ids || []),
scheduleId,
];
await CusProductService.update({
db: req.db,
cusProductId: otherCusProduct.id,
updates: { scheduled_ids: newScheduledIds },
});
}
}
}
}
// Remove scheduled ids from curCusProduct
await CusProductService.update({
db: req.db,
cusProductId: curCusProduct.id,
updates: {
scheduled_ids: curCusProduct.scheduled_ids?.filter(
(id) => !scheduledIds.includes(id),
),
},
});
// 4. Update cus product
logger.info("3. Inserting new full cus product (starts at period end)");
const newProductFree = isFreeProduct(attachParams.prices);
await createFullCusProduct({
db: req.db,
attachParams: attachToInsertParams(attachParams, product),
startsAt: latestPeriodEnd * 1000,
subscriptionScheduleIds: scheduledIds,
nextResetAt: latestPeriodEnd * 1000,
disableFreeTrial: true,
isDowngrade: true,
scenario: newProductFree ? AttachScenario.Cancel : AttachScenario.Downgrade,
});
// 5. Updating current cus product canceled_at...
await CusProductService.update({
db: req.db,
cusProductId: curCusProduct.id,
updates: {
canceled_at: latestPeriodEnd * 1000,
},
});
let apiVersion = attachParams.apiVersion || APIVersion.v1;
if (apiVersion >= APIVersion.v1_1) {
res.status(200).json(
AttachResultSchema.parse({
code: SuccessCode.DowngradeScheduled,
message: `Successfully downgraded from ${curCusProduct.product.name} to ${product.name}`,
product_ids: [product.id],
customer_id:
attachParams.customer.id || attachParams.customer.internal_id,
}),
);
} else {
res.status(200).json({
success: true,
});
}
};

View File

@@ -119,7 +119,10 @@ export const handleStripeSubUpdate = async ({
// 2. Add trial to new subscription?
let trialEnd;
if (!disableFreeTrial) {
trialEnd = freeTrialToStripeTimestamp(attachParams.freeTrial);
trialEnd = freeTrialToStripeTimestamp({
freeTrial: attachParams.freeTrial,
now: attachParams.now,
});
}
// 3. Update current subscription
@@ -297,6 +300,7 @@ const handleOnlyEntsChanged = async ({
disableFreeTrial: false,
keepResetIntervals: true,
carryExistingUsages,
logger,
});
logger.info("✅ Successfully updated entitlements for product");
@@ -502,6 +506,7 @@ export const handleUpgrade = async ({
carryExistingUsages,
carryOverTrial: true,
scenario: AttachScenario.Upgrade,
logger,
});
// Create invoices

View File

@@ -19,6 +19,8 @@ import { z } from "zod";
export type AttachParams = {
stripeCli: Stripe;
stripeCus?: Stripe.Customer;
now?: number;
paymentMethod: Stripe.PaymentMethod | null | undefined;
org: Organization;

View File

@@ -263,7 +263,7 @@ export class CusProductService {
}) {
// sql`${customerProducts.subscription_ids} @> ${sql`ARRAY[${stripeSubId}]`}`,
let data = await db.query.customerProducts.findMany({
where: (table, { and, or, eq, sql, inArray }) =>
where: (table, { and, or, inArray }) =>
and(
or(arrayContains(customerProducts.subscription_ids, [stripeSubId])),
inStatuses ? inArray(customerProducts.status, inStatuses) : undefined,

View File

@@ -3,6 +3,7 @@ import {
FullCusProduct,
FullCustomerEntitlement,
FullCustomerPrice,
getFeatureInvoiceDescription,
Price,
UsagePriceConfig,
} from "@autumn/shared";
@@ -91,10 +92,16 @@ export const getCusPriceUsage = ({
const amount = getPriceForOverage(cusPrice.price, -totalNegativeBalance);
const description = getFeatureInvoiceDescription({
feature: cusEnt.entitlement.feature,
usage,
});
return {
usage, // total usage
overage: -totalNegativeBalance, // usage that's past the allowance
roundedUsage: roundedQuantity, // usage rounded to the nearest billing unit
amount,
description,
};
};

View File

@@ -122,6 +122,7 @@ export const activateDefaultProduct = async ({
org,
env,
curCusProduct,
logger,
}: {
db: DrizzleCli;
productGroup: string;
@@ -129,6 +130,7 @@ export const activateDefaultProduct = async ({
org: Organization;
env: AppEnv;
curCusProduct?: FullCusProduct;
logger: any;
}) => {
// 1. Expire current product
const defaultProducts = await ProductService.listDefault({
@@ -165,6 +167,7 @@ export const activateDefaultProduct = async ({
features: [],
},
scenario: AttachScenario.New,
logger,
});
// console.log(` ✅ activated default product: ${defaultProd.group}`);
@@ -176,11 +179,13 @@ export const expireAndActivate = async ({
env,
cusProduct,
org,
logger,
}: {
db: DrizzleCli;
env: AppEnv;
cusProduct: FullCusProduct;
org: Organization;
logger: any;
}) => {
// 1. Expire current product
await CusProductService.update({
@@ -195,6 +200,7 @@ export const expireAndActivate = async ({
customer: cusProduct.customer!,
org,
env,
logger,
});
};

View File

@@ -212,6 +212,7 @@ export const createNewCustomer = async ({
? getNextStartOfMonthUnix(BillingInterval.Month)
: undefined,
scenario: AttachScenario.New,
logger,
});
}

View File

@@ -29,6 +29,7 @@ import { CusReadService } from "./CusReadService.js";
import { StatusCodes } from "http-status-codes";
import { getAttachPreview } from "../api/entitled/handlers/getAttachPreview.js";
import { cusProductToProduct } from "./cusProducts/cusProductUtils/convertCusProduct.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
export const cusRouter = Router();
@@ -347,6 +348,7 @@ cusRouter.get(
features,
numVersions,
entities: customer.entities,
org: createOrgResponse(org),
});
} catch (error) {
handleFrontendReqError({

View File

@@ -7,7 +7,6 @@ import {
AttachScenario,
BillingType,
CheckProductPreview,
Customer,
Feature,
FullCusProduct,
FullCustomer,

View File

@@ -8,6 +8,8 @@ import {
FullCustomerPrice,
FullProduct,
getFeatureName,
getFeatureNameWithCapital,
Organization,
Price,
UsagePriceConfig,
} from "@autumn/shared";
@@ -21,6 +23,7 @@ import {
getRelatedCusEnt,
} from "../customers/cusProducts/cusPrices/cusPriceUtils.js";
import { getFeatureQuantity } from "../customers/cusProducts/cusProductUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
const getSingularAndPlural = (feature: Feature) => {
const singular = getFeatureName({
@@ -59,48 +62,57 @@ export const formatPrepaidPrice = ({
}
};
export const formatFixedPrice = ({ price }: { price: Price }) => {
const config = price.config as FixedPriceConfig;
if (config.interval == BillingInterval.OneOff) {
return `$${config.amount.toFixed(2)}`;
} else {
return `$${config.amount.toFixed(2)} / ${config.interval}`;
}
};
export const formatUsageInArrear = ({
export const formatFixedPrice = ({
org,
price,
cusProduct,
logger,
}: {
org: Organization;
price: Price;
cusProduct: FullCusProduct;
logger: any;
}) => {
const cusPrice = cusProduct.customer_prices.find(
(cp) => cp.price.id == price.id,
);
const config = price.config as FixedPriceConfig;
const amount = formatAmount({ org, amount: config.amount });
const { usage, overage, roundedUsage } = getCusPriceUsage({
cusPrice: cusPrice!,
cusProduct,
logger,
});
const cusEnt = getRelatedCusEnt({
cusPrice: cusPrice!,
cusEnts: cusProduct.customer_entitlements,
})!;
const { singular, plural } = getSingularAndPlural(cusEnt.entitlement.feature);
if (usage == 1) {
return `${usage} x ${singular}`;
if (config.interval == BillingInterval.OneOff) {
return `${amount}`;
} else {
return `${usage} x ${plural}`;
return `${amount} / ${config.interval}`;
}
};
// export const formatUsageInArrear = ({
// price,
// cusProduct,
// logger,
// }: {
// price: Price;
// feature: Feature;
// cusProduct: FullCusProduct;
// logger: any;
// }) => {
// // const cusPrice = cusProduct.customer_prices.find(
// // (cp) => cp.price.id == price.id,
// // );
// // const { usage, overage, roundedUsage } = getCusPriceUsage({
// // cusPrice: cusPrice!,
// // cusProduct,
// // logger,
// // });
// // const cusEnt = getRelatedCusEnt({
// // cusPrice: cusPrice!,
// // cusEnts: cusProduct.customer_entitlements,
// // })!;
// const { singular, plural } = getSingularAndPlural(cusEnt.entitlement.feature);
// if (usage == 1) {
// return `${usage} x ${singular}`;
// } else {
// return `${usage} x ${plural}`;
// }
// };
export const formatInArrearProrated = ({
price,
ents,
@@ -122,6 +134,7 @@ export const formatInArrearProrated = ({
};
export const priceToInvoiceDescription = ({
org,
price,
cusProduct,
quantity,
@@ -129,6 +142,7 @@ export const priceToInvoiceDescription = ({
}: {
price: Price;
cusProduct: FullCusProduct;
org?: Organization;
quantity?: number;
logger: any;
}) => {
@@ -150,28 +164,28 @@ export const priceToInvoiceDescription = ({
billingType == BillingType.FixedCycle ||
billingType == BillingType.OneOff
) {
description = formatFixedPrice({ price });
description = formatFixedPrice({ org: org!, price });
}
if (billingType == BillingType.InArrearProrated) {
description = formatInArrearProrated({ price, ents, quantity });
}
if (billingType == BillingType.UsageInArrear) {
description = formatUsageInArrear({ price, cusProduct, logger });
}
return `${productName} - ${description}`;
};
export const newPriceToInvoiceDescription = ({
org,
price,
product,
quantity,
withProductPrefix = true,
}: {
org: Organization;
price: Price;
product: FullProduct;
quantity?: number;
withProductPrefix?: boolean;
}) => {
const ents = product.entitlements;
@@ -182,16 +196,17 @@ export const newPriceToInvoiceDescription = ({
billingType == BillingType.FixedCycle ||
billingType == BillingType.OneOff
) {
description = formatFixedPrice({ price });
description = formatFixedPrice({ org, price });
}
if (billingType == BillingType.InArrearProrated) {
description = formatInArrearProrated({ price, ents, quantity });
}
if (billingType == BillingType.UsageInAdvance) {
return null;
if (billingType == BillingType.UsageInArrear) {
const ent = getPriceEntitlement(price, ents);
description = getFeatureNameWithCapital({ feature: ent.feature });
}
return `${product.name} - ${description}`;
return `${withProductPrefix ? `${product.name} - ` : ""}${description}`;
};

View File

@@ -0,0 +1,117 @@
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 { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import Stripe from "stripe";
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 { BillingType, PreviewItem, UsageModel } from "@autumn/shared";
import { priceToInvoiceDescription } from "../invoiceFormatUtils.js";
import { formatUnixToDate, formatUnixToDateTime } from "@/utils/genUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { getProration } from "./getItemsForNewProduct.js";
import { getCusPriceUsage } from "@/internal/customers/cusProducts/cusPrices/cusPriceUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
export const getItemsForCurProduct = ({
stripeSubs,
attachParams,
now,
logger,
}: {
stripeSubs: Stripe.Subscription[];
attachParams: AttachParams;
now: number;
logger: any;
}) => {
const { curMainProduct } = attachParamToCusProducts({ attachParams });
const curCusProduct = curMainProduct!;
const curPrices = cusProductToPrices({ cusProduct: curCusProduct });
const items: PreviewItem[] = [];
for (const sub of stripeSubs) {
for (const item of sub.items.data) {
const price = findPriceInStripeItems({
prices: curPrices,
subItem: item,
});
if (!price) continue;
const billingType = getBillingType(price.config);
if (billingType == BillingType.UsageInArrear) continue;
const totalAmountCents = getSubItemAmount({ subItem: item });
const totalAmount = new Decimal(totalAmountCents).div(100).toNumber();
if (totalAmount == 0) continue;
const periodEnd = sub.current_period_end * 1000;
if (now < periodEnd) {
const finalProration = getProration({
now,
interval: price.config.interval!,
anchorToUnix: sub.current_period_end * 1000,
})!;
const proratedAmount = -calculateProrationAmount({
periodEnd: finalProration?.end,
periodStart: finalProration?.start,
now,
amount: totalAmount,
});
let description = priceToInvoiceDescription({
price,
org: attachParams.org,
cusProduct: curCusProduct,
quantity: item.quantity,
logger,
});
description = `Unused ${description} (from ${formatUnixToDate(now)})`;
// console.log("Item:", description);
// console.log("Period ends:", formatUnixToDateTime(periodEnd));
// console.log("Prorated amount: ", proratedAmount);
// console.log("--------------------------------");
items.push({
price: formatAmount({
org: attachParams.org,
amount: proratedAmount,
}),
description,
amount: proratedAmount,
usage_model: priceToUsageModel(price),
});
}
}
}
for (const price of curPrices) {
let billingType = getBillingType(price.config);
if (billingType == BillingType.UsageInArrear) {
const { amount, description } = getCusPriceUsage({
price,
cusProduct: curCusProduct,
logger,
});
if (!amount) continue;
items.push({
price: formatAmount({
org: attachParams.org,
amount,
}),
description: `${curCusProduct.product.name} - ${description}`,
amount,
});
}
}
return items;
};

View File

@@ -0,0 +1,240 @@
import {
BillingType,
EntitlementWithFeature,
FullProduct,
Organization,
UsagePriceConfig,
PreviewItem,
Price,
Feature,
BillingInterval,
FreeTrial,
} from "@autumn/shared";
import { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import {
getBillingType,
getPriceForOverage,
} from "../../products/prices/priceUtils.js";
import { getPriceEntitlement } from "../../products/prices/priceUtils.js";
import { isFixedPrice } from "../../products/prices/priceUtils/usagePriceUtils.js";
import { getExistingUsageFromCusProducts } from "../../customers/cusProducts/cusEnts/cusEntUtils.js";
import { Decimal } from "decimal.js";
import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js";
import { calculateProrationAmount } from "../prorationUtils.js";
import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js";
import { toProductItem } from "../../products/product-items/mapToItem.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import {
getAlignedIntervalUnix,
getNextStartOfMonthUnix,
subtractBillingIntervalUnix,
} from "../../products/prices/billingIntervalUtils.js";
import { priceToUsageModel } from "@/internal/products/prices/priceUtils/convertPrice.js";
const getDefaultPriceStr = ({
org,
price,
ent,
features,
}: {
org: Organization;
price: Price;
ent: EntitlementWithFeature;
features: Feature[];
}) => {
const item = toProductItem({
ent: ent!,
price,
});
const priceText = getPricecnPrice({
org,
items: [item],
features,
isMainPrice: true,
});
return `${priceText.primaryText} ${priceText.secondaryText}`;
};
export const getProration = ({
proration,
anchorToUnix,
now,
interval,
}: {
proration?: {
start: number;
end: number;
};
anchorToUnix?: number;
interval: BillingInterval;
now: number;
}) => {
if (!proration && !anchorToUnix) return undefined;
if (proration) {
return proration;
}
let end = getAlignedIntervalUnix({
alignWithUnix: anchorToUnix!,
interval,
now,
alwaysReturn: true,
});
let start = subtractBillingIntervalUnix(end!, interval);
return {
start,
end: end!,
};
};
export const getItemsForNewProduct = ({
newProduct,
attachParams,
now,
proration,
interval,
anchorToUnix,
freeTrial,
}: {
newProduct: FullProduct;
attachParams: AttachParams;
now?: number;
proration?: {
start: number;
end: number;
};
interval?: BillingInterval;
anchorToUnix?: number;
freeTrial?: FreeTrial | null;
}) => {
const { org, features } = attachParams;
now = now || Date.now();
const items: PreviewItem[] = [];
for (const price of newProduct.prices) {
const ent = getPriceEntitlement(price, newProduct.entitlements);
const billingType = getBillingType(price.config);
if (interval && price.config.interval !== interval) continue;
const finalProration = getProration({
proration,
anchorToUnix,
now,
interval: price.config.interval!,
});
if (isFixedPrice({ price })) {
const amount = finalProration
? calculateProrationAmount({
periodEnd: finalProration.end,
periodStart: finalProration.start,
now,
amount: getPriceForOverage(price),
})
: getPriceForOverage(price, 0);
let description = newPriceToInvoiceDescription({
org,
price,
product: newProduct,
});
if (proration) {
description = `${description} (from ${formatUnixToDate(now)})`;
}
items.push({
// price: formatAmount({ org, amount }),
price: "",
description,
amount,
usage_model: priceToUsageModel(price),
});
continue;
}
if (billingType == BillingType.UsageInAdvance) continue;
if (billingType == BillingType.UsageInArrear) {
items.push({
price: getDefaultPriceStr({ org, price, ent, features }),
description: newPriceToInvoiceDescription({
org,
price,
product: newProduct,
}),
usage_model: priceToUsageModel(price),
});
continue;
}
const usage = getExistingUsageFromCusProducts({
entitlement: ent,
cusProducts: attachParams.cusProducts,
entities: attachParams.entities,
carryExistingUsages: undefined,
internalEntityId: attachParams.internalEntityId,
});
let description = newPriceToInvoiceDescription({
org,
price,
product: newProduct,
quantity: usage,
});
if (usage == 0) {
items.push({
price: getDefaultPriceStr({ org, price, ent, features }),
description,
usage_model: priceToUsageModel(price),
});
} else {
const overage = new Decimal(usage).sub(ent.allowance!).toNumber();
const amount = finalProration
? calculateProrationAmount({
periodEnd: finalProration.end,
periodStart: finalProration.start,
now,
amount: getPriceForOverage(price, overage),
})
: getPriceForOverage(price, overage);
if (proration) {
description = `${description} (from ${formatUnixToDate(now)})`;
}
items.push({
price: "",
description,
amount,
usage_model: priceToUsageModel(price),
});
}
// // const finalAmount = cycleWillReset ? amount : proratedAmount;
// console.log("Item:", description);
// console.log("Amount: ", amount);
// console.log("--------------------------------");
}
for (const item of items) {
if (item.amount && freeTrial) {
item.amount = 0;
}
if (notNullish(item.amount)) {
item.price = formatAmount({ org, amount: item.amount! });
}
}
return items;
};

View File

@@ -1,5 +1,10 @@
import { Decimal } from "decimal.js";
export type Proration = {
start: number;
end: number;
};
export const calculateProrationAmount = ({
periodEnd,
periodStart,
@@ -14,7 +19,10 @@ export const calculateProrationAmount = ({
const num = new Decimal(periodEnd).minus(now);
const denom = new Decimal(periodEnd).minus(periodStart);
const proratedAmount = num.div(denom).mul(amount).div(100).toDecimalPlaces(2);
const proratedAmount = num.div(denom).mul(amount);
if (proratedAmount.lte(0)) {
return 0;
}
return proratedAmount;
return proratedAmount.toNumber();
};

View File

@@ -36,10 +36,6 @@ mainRouter.use("/test", testRouter);
mainRouter.use(
"/api/autumn",
withOrgAuth,
(req: any, res: any, next: any) => {
// console.log("Autumn middleware:", req.originalUrl);
next();
},
autumnHandler({
identify: async (req: any) => {
return {

View File

@@ -82,10 +82,12 @@ export class OrgService {
db,
orgId,
env,
allowNotFound = false,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
allowNotFound?: boolean;
}) {
const result = (await db.query.organizations.findFirst({
where: eq(organizations.id, orgId),
@@ -99,6 +101,10 @@ export class OrgService {
};
if (!result) {
if (allowNotFound) {
return null;
}
throw new RecaseError({
message: `Organization ${orgId} not found`,
code: ErrCode.OrgNotFound,

View File

@@ -3,6 +3,7 @@ import {
Feature,
FullProduct,
Product,
ProductItem,
ProductV2,
} from "@autumn/shared";
import { mapToProductItems } from "./productV2Utils.js";
@@ -12,29 +13,33 @@ import {
} from "./product-items/compareItemUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { freeTrialsAreSame } from "./free-trials/freeTrialUtils.js";
import { isFeatureItem } from "./product-items/getItemType.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "./product-items/getItemType.js";
export const productsAreSame = ({
v1Product1,
v1Product2,
v2Product1,
v2Product2,
newProductV1,
newProductV2,
curProductV1,
curProductV2,
features,
}: {
v1Product1?: FullProduct;
v1Product2?: FullProduct;
v2Product1?: ProductV2;
v2Product2?: ProductV2;
newProductV1?: FullProduct;
newProductV2?: ProductV2;
curProductV1?: FullProduct;
curProductV2?: ProductV2;
features: Feature[];
}) => {
if (!v1Product1 && !v2Product1) {
if (!newProductV1 && !newProductV2) {
throw new RecaseError({
message: "productsAreSame error: product1 not provided",
code: ErrCode.InvalidRequest,
});
}
if (!v1Product2 && !v2Product2) {
if (!curProductV1 && !curProductV2) {
throw new RecaseError({
message: "productsAreSame error: product2 not provided",
code: ErrCode.InvalidRequest,
@@ -42,18 +47,18 @@ export const productsAreSame = ({
}
let items1 =
v2Product1?.items ||
newProductV2?.items ||
mapToProductItems({
prices: v1Product1?.prices || [],
entitlements: v1Product1?.entitlements || [],
prices: newProductV1?.prices || [],
entitlements: newProductV1?.entitlements || [],
features,
});
let items2 =
v2Product2?.items ||
curProductV2?.items ||
mapToProductItems({
prices: v1Product2?.prices || [],
entitlements: v1Product2?.entitlements || [],
prices: curProductV1?.prices || [],
entitlements: curProductV1?.entitlements || [],
features,
});
@@ -64,12 +69,26 @@ export const productsAreSame = ({
let priceChanged = false;
const newItems: ProductItem[] = [];
for (const item of items1) {
let similarItem = findSimilarItem({
item,
items: items2,
});
if (!similarItem) {
// price is different probs...
if (isFeaturePriceItem(item) || isPriceItem(item)) {
priceChanged = true;
}
itemsSame = false;
newItems.push(item);
continue;
}
if (
!itemsAreSame({
item1: item,
@@ -81,12 +100,13 @@ export const productsAreSame = ({
if (!isFeatureItem(item)) {
priceChanged = true;
}
newItems.push(item);
}
}
// Compare free trial
let freeTrial1 = v1Product1?.free_trial || v2Product1?.free_trial;
let freeTrial2 = v1Product2?.free_trial || v2Product2?.free_trial;
let freeTrial1 = curProductV1?.free_trial || curProductV2?.free_trial;
let freeTrial2 = newProductV1?.free_trial || newProductV2?.free_trial;
let freeTrialsSame = freeTrialsAreSame({
ft1: freeTrial1,
@@ -98,5 +118,6 @@ export const productsAreSame = ({
itemsSame,
freeTrialsSame,
onlyEntsChanged: !itemsSame && !priceChanged,
newItems,
};
};

View File

@@ -58,7 +58,15 @@ export const freeTrialsAreSame = ({
);
};
export const freeTrialToStripeTimestamp = (freeTrial: FreeTrial | null) => {
export const freeTrialToStripeTimestamp = ({
freeTrial,
now,
}: {
freeTrial: FreeTrial | null | undefined;
now?: number | undefined;
}) => {
now = now || Date.now();
if (!freeTrial) return undefined;
let duration = freeTrial.duration || FreeTrialDuration.Day;
@@ -66,11 +74,11 @@ export const freeTrialToStripeTimestamp = (freeTrial: FreeTrial | null) => {
let trialEnd: Date;
if (duration === FreeTrialDuration.Day) {
trialEnd = addDays(new Date(), length);
trialEnd = addDays(new Date(now), length);
} else if (duration === FreeTrialDuration.Month) {
trialEnd = addMonths(new Date(), length);
trialEnd = addMonths(new Date(now), length);
} else if (duration === FreeTrialDuration.Year) {
trialEnd = addYears(new Date(), length);
trialEnd = addYears(new Date(now), length);
} else {
throw new RecaseError({
message: `Invalid free trial duration: ${duration}`,

View File

@@ -15,6 +15,7 @@ import { mapToProductV2 } from "./productV2Utils.js";
import { isFeaturePriceItem } from "./product-items/getItemType.js";
import RecaseError, { handleFrontendReqError } from "@/utils/errorUtils.js";
import { createOrgResponse } from "../orgs/orgUtils.js";
export const productRouter = Router({ mergeParams: true });
@@ -42,15 +43,7 @@ productRouter.get("/data", async (req: any, res) => {
}),
versionCounts: getProductVersionCounts(products),
features,
org: {
id: org.id,
name: org.name,
// test_pkey: org.test_pkey,
// live_pkey: org.live_pkey,
default_currency: org.default_currency,
stripe_connected: org.stripe_connected,
},
// coupons,
org: createOrgResponse(org),
rewards: coupons,
rewardPrograms,
});

View File

@@ -83,10 +83,12 @@ export const getAlignedIntervalUnix = ({
alignWithUnix,
interval,
now,
alwaysReturn,
}: {
alignWithUnix: number;
interval: BillingInterval;
now?: number;
alwaysReturn?: boolean;
}) => {
const nextCycleAnchor = alignWithUnix;
let nextCycleAnchorUnix = nextCycleAnchor;
@@ -122,7 +124,11 @@ export const getAlignedIntervalUnix = ({
new Date(nextCycleAnchorUnix),
) < 60
) {
billingCycleAnchorUnix = undefined;
if (alwaysReturn) {
return naturalBillingDate;
} else {
billingCycleAnchorUnix = undefined;
}
}
return billingCycleAnchorUnix;

View File

@@ -0,0 +1,31 @@
import {
BillingType,
EntitlementWithFeature,
UsageModel,
} from "@autumn/shared";
import { Price } from "@autumn/shared";
import { getBillingType, getPriceEntitlement } from "../priceUtils.js";
import { isFixedPrice } from "./usagePriceUtils.js";
export const priceToFeature = ({
price,
ents,
}: {
price: Price;
ents: EntitlementWithFeature[];
}) => {
const ent = getPriceEntitlement(price, ents);
return ent.feature;
};
export const priceToUsageModel = (price: Price) => {
let billingType = getBillingType(price.config);
if (isFixedPrice({ price })) {
return undefined;
}
if (billingType == BillingType.UsageInAdvance) {
return UsageModel.Prepaid;
}
return UsageModel.PayPerUse;
};

View File

@@ -1,5 +1,6 @@
import {
BillingType,
ErrCode,
Price,
PriceType,
UsagePriceConfig,
@@ -8,6 +9,7 @@ import {
import { Feature } from "@autumn/shared";
import { getBillingType } from "../priceUtils.js";
import Stripe from "stripe";
import RecaseError from "@/utils/errorUtils.js";
export const findPrepaidPrice = ({
prices,
@@ -29,3 +31,34 @@ export const findPrepaidPrice = ({
} else return true;
});
};
export const findPriceForFeature = ({
prices,
feature,
internalFeatureId,
}: {
prices: Price[];
feature?: Feature;
internalFeatureId?: string;
}) => {
if (!feature && !internalFeatureId) {
throw new RecaseError({
message: "findPriceForFeature: No feature or internalFeatureId provided",
code: ErrCode.InternalError,
});
}
return prices.find((p: Price) => {
const config = p.config as UsagePriceConfig;
if (!config.internal_feature_id) {
return false;
}
if (internalFeatureId) {
return config.internal_feature_id == internalFeatureId;
} else {
return config.internal_feature_id == feature!.internal_id;
}
});
};

View File

@@ -8,8 +8,28 @@ const BillingIntervalOrder = [
BillingInterval.OneOff,
];
const ReversedBillingIntervalOrder = [
BillingInterval.OneOff,
BillingInterval.Month,
BillingInterval.Quarter,
BillingInterval.SemiAnnual,
BillingInterval.Year,
];
export const getFirstInterval = ({ prices }: { prices: Price[] }) => {
return BillingIntervalOrder.find((interval) =>
prices.some((price) => price.config.interval === interval),
)!;
};
export const getLastInterval = ({ prices }: { prices: Price[] }) => {
return ReversedBillingIntervalOrder.find((interval) =>
prices.some((price) => price.config.interval === interval),
)!;
};
export const sortBillingIntervals = (intervals: BillingInterval[]) => {
return intervals.sort((a, b) => {
return BillingIntervalOrder.indexOf(a) - BillingIntervalOrder.indexOf(b);
});
};

View File

@@ -18,3 +18,9 @@ export const isFixedPrice = ({ price }: { price: Price }) => {
billingType == BillingType.FixedCycle || billingType == BillingType.OneOff
);
};
export const hasPrepaidPrice = ({ prices }: { prices: Price[] }) => {
return prices.some(
(price) => getBillingType(price.config) == BillingType.UsageInAdvance,
);
};

View File

@@ -16,20 +16,57 @@ import {
billingToItemInterval,
entToItemInterval,
} from "./itemIntervalUtils.js";
import {
calculateProrationAmount,
Proration,
} from "@/internal/invoices/prorationUtils.js";
export const itemToPriceOrTiers = (item: ProductItem) => {
export const itemToPriceOrTiers = ({
item,
proration,
now,
}: {
item: ProductItem;
proration?: Proration;
now?: number;
}) => {
now = now || Date.now();
if (item.price) {
return {
price: item.price,
price: proration
? calculateProrationAmount({
periodEnd: proration.end,
periodStart: proration.start,
now,
amount: item.price,
})
: item.price,
};
} else if (item.tiers) {
if (item.tiers.length > 1) {
return {
tiers: item.tiers,
tiers: item.tiers.map((tier) => ({
...tier,
amount: proration
? calculateProrationAmount({
periodEnd: proration.end,
periodStart: proration.start,
now,
amount: tier.amount,
})
: tier.amount,
})),
};
} else {
return {
price: item.tiers[0].amount,
price: proration
? calculateProrationAmount({
periodEnd: proration.end,
periodStart: proration.start,
now,
amount: item.tiers[0].amount,
})
: item.tiers[0].amount,
};
}
}

View File

@@ -0,0 +1,30 @@
import { FullProduct, Price, ProductV2 } from "@autumn/shared";
import { pricesOnlyOneOff } from "../prices/priceUtils.js";
export const prodIsAddOn = ({ product }: { product: FullProduct }) => {
return product.is_add_on;
};
export const oneOffOrAddOn = ({
product,
prices,
}: {
product: FullProduct;
prices?: Price[];
}) => {
const isOneOff = prices
? pricesOnlyOneOff(prices)
: pricesOnlyOneOff(product.prices);
return prodIsAddOn({ product }) || isOneOff;
};
export const isMainProduct = ({
product,
prices,
}: {
product: FullProduct;
prices?: Price[];
}) => {
return !prodIsAddOn({ product }) && !oneOffOrAddOn({ product, prices });
};

View File

@@ -199,6 +199,7 @@ export const triggerFreeProduct = async ({
await createFullCusProduct({
db,
attachParams: redeemerAttachParams,
logger,
});
logger.info(`✅ Added ${fullProduct.name} to redeemer`);
}
@@ -211,6 +212,7 @@ export const triggerFreeProduct = async ({
customer: referrer,
cusProducts: referrerCusProducts,
},
logger,
});
logger.info(`✅ Added ${fullProduct.name} to referrer`);
}

View File

@@ -0,0 +1,18 @@
import { Organization } from "@autumn/shared";
export const formatAmount = ({
org,
amount,
maxFractionDigits = 2,
}: {
org?: Organization;
amount: number;
maxFractionDigits?: number;
}) => {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: org?.default_currency || "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount);
};

View File

@@ -53,11 +53,11 @@ export const notNullish = (value: any) => {
return !nullish(value);
};
export const formatUnixToDateTime = (unixDate: number) => {
export const formatUnixToDateTime = (unixDate?: number | null) => {
if (!unixDate) {
return null;
return "undefined unix date";
}
return format(new Date(unixDate), "yyyy MMM dd HH:mm:ss");
return format(new Date(unixDate), "dd MMM yyyy HH:mm:ss");
};
export const formatUnixToDate = (unixDate?: number) => {

View File

@@ -1,14 +1,24 @@
import { ProductItem, ProductItemInterval, UsageModel } from "@autumn/shared";
export const constructPrepaidItem = ({ featureId }: { featureId: string }) => {
export const constructPrepaidItem = ({
featureId,
price,
billingUnits = 100,
isOneOff = false,
}: {
featureId: string;
price: number;
billingUnits?: number;
isOneOff?: boolean;
}) => {
let item: ProductItem = {
feature_id: featureId,
usage_model: UsageModel.Prepaid,
price: 100,
billing_units: 100,
price: price,
billing_units: billingUnits || 100,
interval: ProductItemInterval.Month,
interval: isOneOff ? null : ProductItemInterval.Month,
};
return item;
@@ -22,6 +32,7 @@ export const constructArrearItem = ({ featureId }: { featureId: string }) => {
price: 0.1,
billing_units: 1000,
interval: ProductItemInterval.Month,
reset_usage_when_enabled: true,
};
return item;

View File

@@ -6,8 +6,10 @@ import {
import {
AppEnv,
BillingInterval,
CreateFreeTrialSchema,
Feature,
FeatureUsageType,
FreeTrialDuration,
Product,
ProductItem,
ProductV2,
@@ -70,12 +72,14 @@ export const constructProduct = ({
items,
type,
isAnnual = false,
trial = false,
}: {
items: ProductItem[];
type: "free" | "pro" | "premium";
type: "free" | "pro" | "premium" | "one_off";
isAnnual?: boolean;
trial?: boolean;
}) => {
let price = type == "pro" ? 20 : 50;
let price = type == "pro" ? 20 : type == "premium" ? 50 : 0;
if (price) {
items.push(
@@ -94,7 +98,13 @@ export const constructProduct = ({
is_default: false,
version: 1,
group: "",
free_trial: null,
free_trial: trial
? (CreateFreeTrialSchema.parse({
length: 7,
duration: FreeTrialDuration.Day,
unique_fingerprint: false,
}) as any)
: null,
};
return product;

View File

@@ -113,6 +113,12 @@ export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/analyticsModels/actionEnums.js";
export * from "./models/analyticsModels/actionTable.js";
// Attach Models
export * from "./models/attachModels/attachPreviewModels.js";
export * from "./models/attachModels/attachEnums/AttachBranch.js";
export * from "./models/attachModels/attachEnums/AttachFunction.js";
export * from "./models/attachModels/attachEnums/AttachConfig.js";
// Utils
export * from "./utils/displayUtils.js";
export * from "./models/checkModels/checkPreviewModels.js";

View File

@@ -0,0 +1,21 @@
export enum AttachBranch {
MultiProduct = "multi_product",
OneOff = "one_off",
New = "new",
AddOn = "add_on",
// Same product
NewVersion = "new_version",
SameCustomEnts = "same_custom_ents",
SameCustom = "same_custom",
UpdatePrepaidQuantity = "update_prepaid_quantity",
Renew = "renew",
// Handle upgrades / downgrades
MainIsFree = "main_is_free",
MainIsTrial = "main_is_trial",
Upgrade = "upgrade",
Downgrade = "downgrade",
}

View File

@@ -0,0 +1,15 @@
import { AttachBranch } from "./AttachBranch.js";
export enum ProrationBehavior {
Immediately = "immediately",
NextBilling = "next_billing",
None = "none",
}
export interface AttachConfig {
onlyCheckout: boolean;
carryUsage: boolean; // Whether to carry over existing usages
branch: AttachBranch;
proration: ProrationBehavior;
disableTrial: boolean;
}

View File

@@ -0,0 +1,15 @@
export enum AttachFunction {
CreateCheckout = "create_checkout",
AddProduct = "add_product",
UpdateEnts = "update_ents", // only update entitlements
UpdateProduct = "update_product", // update product
ScheduleProduct = "schedule_product",
UpdatePrepaidQuantity = "update_prepaid_quantity",
Renew = "renew",
}
/* Handle checkout / public error:
1. New version, same custom, same custom ents, renew, update prepaid quantity
2.
*/

View File

@@ -0,0 +1,7 @@
import { AttachBranch } from "./attachEnums/AttachBranch.js";
export interface AttachPreview {
branch: AttachBranch;
options: any;
new_items: any;
}

View File

@@ -16,6 +16,13 @@ export enum AttachScenario {
Expired = "expired",
}
export interface PreviewItem {
price: string;
description: string;
usage_model?: UsageModel;
amount?: number;
}
export interface CheckProductPreview {
title: string;
message: string;

View File

@@ -41,3 +41,36 @@ export const getFeatureNameWithCapital = ({
return feature.name;
};
export const getSingularAndPlural = ({
feature,
capitalize = false,
}: {
feature: Feature;
capitalize?: boolean;
}) => {
return {
singular: getFeatureName({ feature, plural: false, capitalize }),
plural: getFeatureName({ feature, plural: true, capitalize }),
};
};
export const getFeatureInvoiceDescription = ({
feature,
usage,
billingUnits = 1,
}: {
feature: Feature;
usage: number;
billingUnits?: number;
}) => {
const { singular, plural } = getSingularAndPlural({ feature });
if (billingUnits == 1) {
if (usage == 1)
return `${usage} ${singular}`; // eg. 1 credit
else return `${usage} ${plural}`; // eg. 4 credits
} else {
return `${usage} x ${billingUnits} ${plural}`; // eg. 4 x 100 credits
}
};

View File

@@ -11,7 +11,7 @@ export const PriceItem = ({
return (
<div
className={cn(
"flex flex-col sm:flex-row text-muted-foreground pb-4 sm:pb-0 gap-1 justify-between sm:gap-2 sm:items-center sm:whitespace-nowrap",
"flex h-7 flex-col sm:flex-row text-muted-foreground pb-4 sm:pb-0 gap-1 justify-between sm:gap-2 sm:items-center sm:whitespace-nowrap",
className,
)}
{...props}

View File

@@ -5,6 +5,11 @@ export const formatTimestamp = (timestamp: number | null | undefined) => {
return format(new Date(timestamp), "MM/dd/yyyy");
};
export const formatUnixToDate = (unix: number | null | undefined) => {
if (!unix) return "";
return format(new Date(unix), "d MMM yyyy");
};
export const formatUnixToDateTime = (unix: number | null | undefined) => {
if (!unix) return { date: "", time: "" };
const date = format(new Date(unix), "d MMM");

View File

@@ -18,9 +18,17 @@ export const slugify = (text: string) => {
.replace(/[^\w\s-]/g, "");
};
export const formatCurrency = (amount: number, currency: string = "USD") => {
export const formatAmount = ({
amount,
currency,
}: {
amount: number;
currency: string;
}) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 10,
}).format(amount);
};

View File

@@ -11,6 +11,11 @@ export const getStripeSubLink = (subscriptionId: string, env: AppEnv) => {
env == AppEnv.Live ? "" : "/test"
}/subscriptions/${subscriptionId}`;
};
export const getStripeSubScheduleLink = (scheduledId: string, env: AppEnv) => {
return `https://dashboard.stripe.com${
env == AppEnv.Live ? "" : "/test"
}/subscription_schedules/${scheduledId}`;
};
export const getStripeInvoiceLink = (stripeInvoice: any) => {
return `https://dashboard.stripe.com${

View File

@@ -1,10 +1,14 @@
import {
Feature,
FeatureType,
Infinite,
Organization,
ProductItem,
ProductItemType,
} from "@autumn/shared";
import { formatAmount, getItemType, intervalIsNone } from "../productItemUtils";
import { getFeature } from "../entitlementUtils";
import { notNullish } from "@/utils/genUtils";
const getPaidFeatureString = ({
item,
@@ -37,7 +41,7 @@ const getPaidFeatureString = ({
})}`;
}
let feature = features.find((f: Feature) => f.id == item.feature_id);
const feature = features.find((f: Feature) => f.id == item.feature_id);
amountStr += ` per ${item.billing_units! > 1 ? item.billing_units : ""} ${
feature?.name
@@ -61,8 +65,8 @@ const getFixedPriceString = ({
item: ProductItem;
org: Organization;
}) => {
let currency = org?.default_currency || "USD";
let formattedAmount = formatAmount({
const currency = org?.default_currency || "USD";
const formattedAmount = formatAmount({
defaultCurrency: currency,
amount: item.price!,
});
@@ -74,6 +78,26 @@ const getFixedPriceString = ({
return `${formattedAmount}`;
};
export const getFeatureString = ({
item,
features,
}: {
item: ProductItem;
features: Feature[];
}) => {
const feature = features.find((f: Feature) => f.id == item.feature_id);
if (feature?.type === FeatureType.Boolean) {
return `${feature.name}`;
}
if (item.included_usage == Infinite) {
return `Unlimited ${feature?.name}`;
}
return `${item.included_usage ?? 0} ${feature?.name}${item.entity_feature_id ? ` per ${getFeature(item.entity_feature_id, features)?.name}` : ""}${notNullish(item.interval) ? ` per ${item.interval}` : ""}`;
};
export const formatProductItemText = ({
item,
org,
@@ -83,7 +107,7 @@ export const formatProductItemText = ({
org: Organization;
features: Feature[];
}) => {
let itemType = getItemType(item);
const itemType = getItemType(item);
if (itemType == ProductItemType.FeaturePrice) {
return getPaidFeatureString({ item, org, features });

View File

@@ -16,15 +16,17 @@ export const itemIsUnlimited = (item: ProductItem) => {
export const formatAmount = ({
defaultCurrency,
amount,
maxFractionDigits = 6,
}: {
defaultCurrency: string;
amount: number;
maxFractionDigits?: number;
}) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: defaultCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 6,
maximumFractionDigits: maxFractionDigits || 6,
}).format(amount);
};

View File

@@ -0,0 +1,21 @@
import { ProductItemType } from "@autumn/shared";
import { ProductItem } from "@autumn/shared";
import { getItemType } from "./product/productItemUtils";
export const sortProductItems = (items: ProductItem[]) => {
const sortedItems = [...items].sort((a, b) => {
const typeA = getItemType(a);
const typeB = getItemType(b);
const typeOrder = {
[ProductItemType.Feature]: 0,
[ProductItemType.FeaturePrice]: 1,
[ProductItemType.Price]: 2,
};
return typeOrder[typeA] - typeOrder[typeB];
});
return sortedItems;
};

View File

@@ -33,6 +33,7 @@ import { AdminHover } from "@/components/general/AdminHover";
import AddProduct from "./add-product/NewProductDropdown";
import { Item, Row } from "@/components/general/TableGrid";
import { cn } from "@/lib/utils";
import { CusProductStripeLink } from "./components/CusProductStripeLink";
export const CustomerProductList = ({
customer,
@@ -59,7 +60,7 @@ export const CustomerProductList = ({
p.entitlements.some(
(cusEnt: any) =>
cusEnt.entities &&
Object.keys(cusEnt.entities).includes(entity.internal_id)
Object.keys(cusEnt.entities).includes(entity.internal_id),
)
: true;
@@ -88,7 +89,7 @@ export const CustomerProductList = ({
variant="ghost"
className={cn(
"text-t3 text-xs font-normal p-0",
showExpired && "text-t1 hover:text-t1"
showExpired && "text-t1 hover:text-t1",
)}
size="sm"
onClick={() => setShowExpired(!showExpired)}
@@ -119,8 +120,8 @@ export const CustomerProductList = ({
key={cusProduct.id}
className="grid-cols-12 pr-0"
onClick={() => {
let entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id
const entity = entities.find(
(e: any) => e.internal_id === cusProduct.internal_entity_id,
);
navigateTo(
`/customers/${customer.id || customer.internal_id}/${
@@ -129,7 +130,7 @@ export const CustomerProductList = ({
entity ? `&entity_id=${entity.id || entity.internal_id}` : ""
}`,
navigate,
env
env,
);
}}
>
@@ -188,30 +189,7 @@ export const CustomerProductList = ({
scheduled
</Badge>
)}
{cusProduct.subscription_ids &&
cusProduct.subscription_ids.length > 0 && (
<React.Fragment>
{cusProduct.subscription_ids.map((subId: string) => {
return (
<Link
key={subId}
to={getStripeSubLink(subId, env)}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
<ArrowUpRightFromSquare
size={12}
className="text-[#665CFF]"
/>
</div>
</Link>
);
})}
</React.Fragment>
)}
<CusProductStripeLink cusProduct={cusProduct} />
</div>
</Item>
<Item className="col-span-2 text-xs text-t3">
@@ -285,7 +263,7 @@ const UpdateStatusDropdownBtn = ({
cusProduct.id,
{
status,
}
},
);
await cusMutate();
} catch (error) {

View File

@@ -0,0 +1,67 @@
import { useEnv } from "@/utils/envUtils";
import { getStripeSubLink, getStripeSubScheduleLink } from "@/utils/linkUtils";
import { CusProductStatus, FullCusProduct } from "@autumn/shared";
import { ArrowUpRightFromSquare } from "lucide-react";
import React from "react";
import { Link } from "react-router";
export const CusProductStripeLink = ({
cusProduct,
}: {
cusProduct: FullCusProduct;
}) => {
const env = useEnv();
return (
<>
{cusProduct.subscription_ids &&
cusProduct.subscription_ids.length > 0 && (
<React.Fragment>
{cusProduct.subscription_ids.map((subId: string) => {
return (
<Link
key={subId}
to={getStripeSubLink(subId, env)}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
<ArrowUpRightFromSquare
size={12}
className="text-[#665CFF]"
/>
</div>
</Link>
);
})}
</React.Fragment>
)}
{cusProduct.status == CusProductStatus.Scheduled &&
cusProduct.scheduled_ids &&
cusProduct.scheduled_ids.length > 0 && (
<React.Fragment>
{cusProduct.scheduled_ids.map((subId: string) => {
return (
<Link
key={subId}
to={getStripeSubScheduleLink(subId, env)}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className="flex justify-center items-center w-fit px-2 gap-2 h-6">
<ArrowUpRightFromSquare
size={12}
className="text-[#665CFF]"
/>
</div>
</Link>
);
})}
</React.Fragment>
)}
</>
);
};

View File

@@ -1,7 +1,8 @@
"use client";
import ProductSidebar from "@/views/products/product/ProductSidebar";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import {
Customer,
Entity,
@@ -14,16 +15,6 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
import { CustomToaster } from "@/components/general/CustomToaster";
import { ManageProduct } from "@/views/products/product/ManageProduct";
import { ProductContext } from "@/views/products/product/ProductContext";
import { CusService } from "@/services/customers/CusService";
import { toast } from "sonner";
import {
BreadcrumbItem,
Breadcrumb,
BreadcrumbList,
BreadcrumbSeparator,
BreadcrumbLink,
} from "@/components/ui/breadcrumb";
import {
Dialog,
@@ -33,26 +24,11 @@ import {
} from "@/components/ui/dialog";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import {
getBackendErr,
getBackendErrObj,
getRedirectUrl,
navigateTo,
} from "@/utils/genUtils";
import { ErrCode } from "@autumn/shared";
import { AddProductButton } from "../add-product/AddProductButton";
import ErrorScreen from "@/views/general/ErrorScreen";
import { ProductService } from "@/services/products/ProductService";
import RequiredOptionsModal from "./RequiredOptionsModal";
import { ProductOptions } from "./ProductOptions";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import ProductSidebar from "@/views/products/product/ProductSidebar";
import { FeaturesContext } from "@/views/features/FeaturesContext";
import { CustomerProductBreadcrumbs } from "./components/CustomerProductBreadcrumbs";
import { useAttachState } from "./hooks/useAttachState";
@@ -122,12 +98,14 @@ export default function CustomerProductView() {
const [url, setUrl] = useState<any>(null);
const [checkoutDialogOpen, setCheckoutDialogOpen] = useState(false);
const [requiredOptions, setRequiredOptions] = useState<OptionValue[]>([]);
const [useInvoice, setUseInvoice] = useState(false);
const [selectedEntitlementAllowance, setSelectedEntitlementAllowance] =
useState<"unlimited" | number>(0);
const attachState = useAttachState({ product, preview: data?.preview });
const attachState = useAttachState({
product,
setProduct,
preview: data?.preview,
});
useEffect(() => {
if (!data?.product || !data?.customer) return;
@@ -188,8 +166,6 @@ export default function CustomerProductView() {
setSelectedEntitlementAllowance,
customer: customer as Customer,
entities: data.entities as Entity[],
setUseInvoice,
entityId,
setEntityId,
attachState,

View File

@@ -41,7 +41,7 @@ const getAttachBody = ({
options: optionsInput
? optionsInput.map((option) => ({
feature_id: option.feature_id,
quantity: option.quantity,
quantity: option.quantity || 0,
}))
: undefined,
is_custom: isCustom,
@@ -54,11 +54,13 @@ const getAttachBody = ({
};
export const AttachButton = () => {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState<any>(null);
const { attachState, product, entityId, customer } = useProductContext();
const [buttonLoading, setButtonLoading] = useState(false);
const axios = useAxiosInstance();
const [open, setOpen] = useState(false);
const [buttonLoading, setButtonLoading] = useState(false);
const { attachState, product, entityId, customer } = useProductContext();
const { preview, setPreview } = attachState;
const { buttonText } = attachState;
const handleAttachClicked = async () => {
@@ -74,8 +76,7 @@ export const AttachButton = () => {
}),
);
setPreview(res.data.preview);
setPreview(res.data);
setOpen(true);
} catch (error) {
toast.error(getBackendErr(error, "Failed to attach product"));
@@ -85,7 +86,7 @@ export const AttachButton = () => {
return (
<>
<AttachModal open={open} setOpen={setOpen} preview={preview} />
<AttachModal open={open} setOpen={setOpen} />
<Button
onClick={handleAttachClicked}
variant="gradientPrimary"

View File

@@ -1,7 +1,6 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -9,27 +8,17 @@ import {
import { DialogContent } from "@/components/ui/dialog";
import { useProductContext } from "@/views/products/product/ProductContext";
import {
AttachBranch,
AttachScenario,
CheckProductPreview,
Entity,
ErrCode,
FeatureOptions,
} from "@autumn/shared";
import { AttachCase } from "../hooks/useAttachState";
import { useState } from "react";
import {
ArrowRight,
ArrowUpRightFromSquare,
InfoIcon,
Link,
ArrowLeft,
} from "lucide-react";
import {
PriceItem,
TotalPrice,
QuantityInput,
} from "@/components/pricing/attach-pricing-dialog";
import { ArrowUpRightFromSquare, InfoIcon } from "lucide-react";
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import {
getBackendErr,
getBackendErrObj,
@@ -44,15 +33,16 @@ import { useEnv } from "@/utils/envUtils";
import { getStripeInvoiceLink } from "@/utils/linkUtils";
import { AttachPreviewDetails } from "./AttachPreviewDetails";
import { ToggleConfigButton } from "./ToggleConfigButton";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import { AttachInfo } from "./attach-preview/AttachInfo";
export const AttachModal = ({
open,
setOpen,
preview,
}: {
open: boolean;
setOpen: (open: boolean) => void;
preview: CheckProductPreview;
}) => {
const { product, customer, entities, entityId, attachState } =
useProductContext();
@@ -61,17 +51,7 @@ export const AttachModal = ({
const env = useEnv();
const axiosInstance = useAxiosInstance();
const { attachCase } = attachState;
const [optionsInput, setOptionsInput] = useState<
{
feature_id: string;
feature_name: string;
billing_units: number;
price?: number;
quantity?: number;
}[]
>(preview?.options || []);
const { preview, options, setOptions, flags } = attachState;
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [invoiceLoading, setInvoiceLoading] = useState(false);
@@ -96,20 +76,26 @@ export const AttachModal = ({
return cusId;
};
const getAttachDescription = () => {
switch (attachCase) {
case AttachCase.Checkout:
return "This customer does not have a card on file.";
case AttachScenario.New:
return "Clicking confirm will create a new product for the customer.";
case AttachScenario.Upgrade:
return `The customer is upgrading from ${preview?.current_product_name} to ${product.name}. This will happen immediately.`;
default:
return "Attach the product to the customer.";
const invoiceAllowed = () => {
if (preview?.branch == AttachBranch.SameCustomEnts || flags.isFree) {
return false;
}
if (preview?.branch == AttachBranch.Downgrade) {
return false;
}
return true;
};
const getButtonText = () => {
if (preview?.branch == AttachBranch.Downgrade) {
return "Confirm Downgrade";
}
if (preview?.branch == AttachBranch.SameCustomEnts || flags.isFree) {
return "Confirm";
}
if (!preview?.payment_method) {
return "Checkout Page";
}
@@ -137,17 +123,16 @@ export const AttachModal = ({
const { data } = await CusService.attach(axiosInstance, customer.id, {
product_id: product.id,
entity_id: entityId || undefined,
options: optionsInput
? optionsInput.map((option) => ({
options: options
? options.map((option: any) => ({
feature_id: option.feature_id,
quantity: option.quantity,
quantity: option.quantity || 0,
}))
: undefined,
is_custom: isCustom,
...customData,
invoice_only: useInvoice,
free_trial: product.free_trial || undefined,
success_url: `${import.meta.env.VITE_PUBLIC_FRONTEND_URL}${redirectUrl}`,
});
@@ -184,11 +169,14 @@ export const AttachModal = ({
const [configOpen, setConfigOpen] = useState(false);
const mainWidth = "w-lg";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-fit gap-0 p-0 rounded-xs">
<DialogContent className="gap-0 p-0 rounded-xs">
<div className="flex transition-all duration-300 ease-in-out">
<div className="p-6 pb-2 flex flex-col gap-4 w-md rounded-sm">
<div
className={`p-6 pb-2 flex flex-col gap-4 ${mainWidth} rounded-sm`}
>
<DialogHeader>
<DialogTitle className="text-t2 text-md">
Attach product
@@ -196,7 +184,7 @@ export const AttachModal = ({
</DialogHeader>
<div className="text-sm flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex flex-col">
<p className="text-t2 font-semibold mb-2">Details</p>
<PriceItem>
<span>Product</span>
@@ -208,23 +196,10 @@ export const AttachModal = ({
</PriceItem>
</div>
{preview && (
<>
<div className="h-px bg-zinc-200"></div>
<AttachPreviewDetails
options={optionsInput}
setOptions={setOptionsInput}
preview={preview}
/>
</>
)}
</div>
<div className="flex items-center p-2 bg-blue-50 border-1 border-blue-200 text-blue-400 rounded-xs">
<div className="min-w-6 flex">
<InfoIcon size={14} />
</div>
<p className="text-sm">{getAttachDescription()}</p>
{preview && !flags.isFree && <AttachPreviewDetails />}
<AttachInfo />
</div>
<div className="flex justify-end">
<ToggleConfigButton
configOpen={configOpen}
@@ -243,20 +218,27 @@ export const AttachModal = ({
</div>
</div>
<DialogFooter className="bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200">
<Button
variant="add"
className="!h-full text-t2"
endIcon={<ArrowUpRightFromSquare size={12} />}
disableStartIcon={true}
tabIndex={-1}
tooltipContent="This will enable the product for the customer immediately, and redirect you to Stripe to finalize the invoice"
isLoading={invoiceLoading}
disabled={invoiceLoading || checkoutLoading}
onClick={() => handleAttachClicked(true)}
>
Invoice Customer
</Button>
<DialogFooter
className={cn(
"bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200",
mainWidth,
)}
>
{invoiceAllowed() && (
<Button
variant="add"
className="!h-full text-t2"
endIcon={<ArrowUpRightFromSquare size={12} />}
disableStartIcon={true}
tabIndex={-1}
tooltipContent="This will enable the product for the customer immediately, and redirect you to Stripe to finalize the invoice"
isLoading={invoiceLoading}
disabled={invoiceLoading || checkoutLoading}
onClick={() => handleAttachClicked(true)}
>
Invoice Customer
</Button>
)}
<Button
variant="add"
className="!h-full"

View File

@@ -2,77 +2,33 @@ import {
PriceItem,
QuantityInput,
} from "@/components/pricing/attach-pricing-dialog";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { formatAmount } from "@/utils/formatUtils/formatTextUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { Feature, getFeatureInvoiceDescription } from "@autumn/shared";
import { Separator } from "@/components/ui/separator";
import React from "react";
import { AttachNewItems } from "./attach-preview/AttachNewItems";
import { DueToday } from "./attach-preview/DueToday";
import { DueNextCycle } from "./attach-preview/DueNextCycle";
export const AttachPreviewDetails = ({
options,
setOptions,
preview,
}: {
options: any;
setOptions: any;
preview: any;
}) => {
const getTotalPrice = () => {
let total = preview?.due_today?.price || 0;
options.forEach((option: any) => {
if (option.price && option.quantity) {
total += option.price * (option.quantity / option.billing_units);
}
});
return total;
};
export const AttachPreviewDetails = () => {
const { product, features, org, attachState } = useProductContext();
const { preview, options, setOptions } = attachState;
const currency = org.default_currency || "USD";
const dueTodayItems = preview?.due_today?.line_items || [];
if (!preview) {
return null;
}
return (
<div className="flex flex-col gap-1">
<p className="text-t2 font-semibold mb-2">Amount</p>
{preview &&
preview.items &&
preview.items.length > 0 &&
preview.items.map((item: any) => {
const { description, price } = item;
return (
<PriceItem key={description}>
<span>{description}</span>
<span>{price}</span>
</PriceItem>
);
})}
{options.length > 0 &&
options.map((option: any, index: number) => {
const { feature_name, billing_units, quantity, price } = option;
return (
<PriceItem key={feature_name}>
<span>{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);
}}
>
<span className="text-muted-foreground">
× ${price} per {billing_units === 1 ? " " : billing_units}{" "}
{feature_name}
</span>
</QuantityInput>
</PriceItem>
);
})}
{preview && preview.due_today && (
<PriceItem className="font-semibold">
<span>Due today</span>
<span>
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: preview.due_today.currency,
}).format(getTotalPrice())}
</span>
</PriceItem>
)}
</div>
<React.Fragment>
<DueToday />
<AttachNewItems />
<DueNextCycle />
</React.Fragment>
);
};

View File

@@ -0,0 +1,50 @@
import {
PriceItem,
QuantityInput,
} from "@/components/pricing/attach-pricing-dialog";
import { formatAmount } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import React from "react";
export const AdjustableOptions = () => {
const { attachState, product, org } = useProductContext();
const { options, setOptions } = attachState;
const currency = org.default_currency || "USD";
if (options.length == 0) return null;
return (
<React.Fragment>
{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>
<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);
}}
>
<span className="text-muted-foreground">
×{" "}
{formatAmount({
defaultCurrency: currency,
amount: price,
maxFractionDigits: 2,
})}{" "}
per {billing_units === 1 ? " " : billing_units} {feature_name}
</span>
</QuantityInput>
</PriceItem>
);
})}
</React.Fragment>
);
};

View File

@@ -0,0 +1,40 @@
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { AttachBranch } from "@autumn/shared";
import { InfoIcon } from "lucide-react";
export const AttachInfo = () => {
const { attachState, product } = useProductContext();
const { preview, flags } = attachState;
const currentProduct = preview?.current_product;
const getAttachDescription = () => {
switch (preview?.branch) {
case AttachBranch.SameCustomEnts:
return "No changes to prices or subscriptions will be made";
case AttachBranch.Downgrade:
if (flags.isFree) {
return `This customers' `;
} else {
return `The customer is currently on ${currentProduct.name} and will downgrade to ${product.name} on ${formatUnixToDate(preview.due_next_cycle.due_at)}`;
}
default:
return null;
}
};
const description = getAttachDescription();
if (!description) {
return null;
}
return (
<div className="flex items-center p-2 bg-blue-50 border-1 border-blue-200 text-blue-400 rounded-xs">
<div className="min-w-6 flex">
<InfoIcon size={14} />
</div>
<p className="text-sm">{getAttachDescription()}</p>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { getFeatureString } from "@/utils/product/product-item/formatProductItem";
import { useProductContext } from "@/views/products/product/ProductContext";
export const AttachNewItems = () => {
const { attachState, features } = useProductContext();
const { preview } = attachState;
if (preview?.new_items) {
return (
<div>
<p className="text-t2 font-semibold mb-2">New items</p>
{preview.new_items.map((item: any, index: number) => {
return (
<PriceItem key={index}>
<span>{getFeatureString({ item, features })}</span>
</PriceItem>
);
})}
</div>
);
}
};

View File

@@ -0,0 +1,69 @@
import { AttachBranch, Feature, features } from "@autumn/shared";
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { formatUnixToDate } from "@/utils/formatUtils/formatDateUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { getFeatureInvoiceDescription } from "@autumn/shared";
import { formatAmount } from "@/utils/formatUtils/formatTextUtils";
import { AdjustableOptions } from "./AdjustQuantity";
export const DueNextCycle = () => {
const { attachState, product, features, org } = useProductContext();
const preview = attachState.preview;
const currency = org.default_currency || "USD";
const getPrepaidPrice = ({ option }: { option: any }) => {
const quantity = (option.quantity || 0) / option.billing_units;
return formatAmount({
amount: option.full_price * quantity,
currency,
});
};
const branch = attachState.preview?.branch;
if (!preview.due_next_cycle) return null;
return (
<div className="flex flex-col">
<p className="text-t2 font-semibold mb-2">
Next cycle: {formatUnixToDate(preview.due_next_cycle.due_at)}
</p>
{preview.due_next_cycle.line_items.map((item: any) => {
const { description, price } = item;
return (
<PriceItem key={description}>
<span>{description}</span>
<span>{price}</span>
</PriceItem>
);
})}
{branch == AttachBranch.Downgrade ? (
<AdjustableOptions />
) : (
<>
{preview.options.map((option: any) => {
const quantity = Math.ceil(option.quantity / option.billing_units);
const description = getFeatureInvoiceDescription({
feature: features.find(
(f: Feature) => f.id === option.feature_id,
)!,
usage: quantity || 0,
billingUnits: option.billing_units,
});
return (
<PriceItem key={option.feature_name}>
<span>
{product.name} - {description}
</span>
<span>{getPrepaidPrice({ option })}</span>
</PriceItem>
);
})}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,107 @@
import { QuantityInput } from "@/components/pricing/attach-pricing-dialog";
import { useProductContext } from "@/views/products/product/ProductContext";
import { AttachNewItems } from "./AttachNewItems";
import { PriceItem } from "@/components/pricing/attach-pricing-dialog";
import { formatAmount } from "@/utils/product/productItemUtils";
import { AttachBranch } from "@autumn/shared";
import { Decimal } from "decimal.js";
export const DueToday = () => {
const { attachState, product, org } = useProductContext();
const { preview, options, setOptions } = attachState;
const dueToday = preview.due_today;
if (!dueToday) {
return null;
}
const dueTodayItems = dueToday.line_items;
const currency = org?.default_currency || "USD";
const branch = preview.branch;
const getTotalPrice = () => {
let total = preview?.due_today?.total || 0;
options.forEach((option: any) => {
if (option.price && option.quantity) {
total = new Decimal(total)
.plus(
new Decimal(option.price).times(
new Decimal(option.quantity).div(option.billing_units),
),
)
.toNumber();
}
});
return total;
};
const getTitle = () => {
if (branch == AttachBranch.UpdatePrepaidQuantity) {
return "Update quantity";
}
return "Due today";
};
return (
<div className="flex flex-col">
<p className="text-t2 font-semibold mb-2">{getTitle()}</p>
{dueTodayItems &&
dueTodayItems.map((item: any) => {
const { description, price } = item;
return (
<PriceItem key={description}>
<span>{description}</span>
<span>{price}</span>
</PriceItem>
);
})}
<AttachNewItems />
{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>
<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);
}}
>
<span className="text-muted-foreground">
×{" "}
{formatAmount({
defaultCurrency: currency,
amount: price,
maxFractionDigits: 2,
})}{" "}
per {billing_units === 1 ? " " : billing_units} {feature_name}
</span>
</QuantityInput>
</PriceItem>
);
})}
{preview.due_today && (
<PriceItem className="font-bold mt-2">
<span>Total:</span>
<span>
{formatAmount({
amount: getTotalPrice(),
defaultCurrency: currency,
maxFractionDigits: 2,
})}
</span>
</PriceItem>
)}
</div>
);
};

View File

@@ -1,9 +1,16 @@
import { isFeatureItem } from "@/utils/product/getItemType";
import { isOneOffProduct } from "@/utils/product/priceUtils";
import { sortProductItems } from "@/utils/productUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import {
AttachPreview,
AttachScenario,
BillingInterval,
CheckProductPreview,
FeatureOptions,
ProductItem,
ProductV2,
UsageModel,
} from "@autumn/shared";
import { useEffect, useRef, useState } from "react";
@@ -20,36 +27,98 @@ export enum AttachCase {
Checkout = "Checkout",
}
const productHasPrepaid = (items: ProductItem[]) => {
return items.some((item) => item.usage_model == UsageModel.Prepaid);
};
const productIsAddOn = (product: FrontendProduct) => {
return product.is_add_on;
};
const productIsFree = (product: FrontendProduct) => {
return product.items.every((item) => isFeatureItem(item));
};
export const useAttachState = ({
product,
preview,
setProduct,
}: {
product: FrontendProduct | null;
preview?: CheckProductPreview | null;
setProduct: (product: FrontendProduct) => void;
}) => {
const initialProductRef = useRef<FrontendProduct | null>(null);
const [preview, setPreview] = useState<AttachPreview | null>(null);
const [options, setOptions] = useState<
(FeatureOptions & {
full_price: number;
billing_units: number;
})[]
>([]);
const [itemsChanged, setItemsChanged] = useState(false);
const [flags, setFlags] = useState({
hasPrepaid: product ? productHasPrepaid(product.items) : false,
isAddOn: product ? productIsAddOn(product) : false,
isFree: product ? productIsFree(product) : false,
});
useEffect(() => {
if (preview?.options) {
setOptions(preview.options);
}
}, [preview]);
const initFlags = () => {
setFlags({
hasPrepaid: product ? productHasPrepaid(product.items) : false,
isAddOn: product ? productIsAddOn(product) : false,
isFree: product ? productIsFree(product) : false,
});
};
useEffect(() => {
if (!product) {
return;
}
const sortedProduct = {
...product,
items: sortProductItems(product.items),
};
if (JSON.stringify(product.items) !== JSON.stringify(sortedProduct.items)) {
setProduct(sortedProduct);
}
initFlags();
if (!initialProductRef.current) {
initialProductRef.current = structuredClone(product);
initialProductRef.current = structuredClone(sortedProduct);
setItemsChanged(false);
return;
}
const hasItemsChanged =
JSON.stringify(product.items) !==
JSON.stringify(sortedProduct.items) !==
JSON.stringify(initialProductRef.current.items);
setItemsChanged(hasItemsChanged);
}, [product]);
const buttonDisabled = product?.isActive && !itemsChanged;
const getButtonDisabled = () => {
if (product?.isActive && !itemsChanged) {
if (flags.hasPrepaid) {
return false;
}
if (flags.isAddOn) {
return false;
}
return true;
}
return false;
};
const getAttachCase = () => {
if (!product) {
@@ -72,28 +141,35 @@ export const useAttachState = ({
return AttachCase.Custom;
}
if (!preview || !preview.payment_method) {
return AttachCase.Checkout;
}
// if (!preview || !preview.payment_method) {
// return AttachCase.Checkout;
// }
return preview.scenario;
// return preview.scenario;
};
const getButtonText = () => {
const attachCase = getAttachCase();
switch (attachCase) {
case AttachCase.Custom:
return `Attach Custom Version`;
default:
return "Attach Product";
if (product?.isActive && !itemsChanged) {
if (flags.hasPrepaid) {
return "Update prepaid quantity";
}
}
return "Attach Product";
};
return {
itemsChanged,
buttonDisabled,
buttonDisabled: getButtonDisabled(),
buttonText: getButtonText(),
attachCase: getAttachCase(),
preview,
setPreview,
options,
setOptions,
flags,
setFlags,
};
};

View File

@@ -33,6 +33,7 @@ import { FeaturesContext } from "@/views/features/FeaturesContext";
import ProductViewBreadcrumbs from "./components/ProductViewBreadcrumbs";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
import { getItemType } from "@/utils/product/productItemUtils";
import { sortProductItems } from "@/utils/productUtils";
function ProductView({ env }: { env: AppEnv }) {
const { product_id } = useParams();
@@ -255,7 +256,7 @@ function ProductView({ env }: { env: AppEnv }) {
<ManageProduct />
</div>
</div>
<div className="flex justify-end gap-2 p-10 w-full lg:hidden block">
<div className="flex justify-end gap-2 p-10 w-full lg:hidden">
<div className="w-fit">
<AddProductButton />
</div>
@@ -271,20 +272,3 @@ function ProductView({ env }: { env: AppEnv }) {
}
export default ProductView;
const sortProductItems = (items: ProductItem[]) => {
const sortedItems = [...items].sort((a, b) => {
const typeA = getItemType(a);
const typeB = getItemType(b);
const typeOrder = {
[ProductItemType.Feature]: 0,
[ProductItemType.FeaturePrice]: 1,
[ProductItemType.Price]: 2,
};
return typeOrder[typeA] - typeOrder[typeB];
});
return sortedItems;
};