adding attach preview
This commit is contained in:
15
server/src/external/stripe/stripeCusUtils.ts
vendored
15
server/src/external/stripe/stripeCusUtils.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
server/src/external/stripe/stripeSubUtils.ts
vendored
15
server/src/external/stripe/stripeSubUtils.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,8 +50,6 @@ export const getSubItemAmount = ({
|
||||
quantity,
|
||||
});
|
||||
|
||||
console.log("Tiered amount:", tieredAmount);
|
||||
|
||||
return tieredAmount;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
50
server/src/external/stripe/stripeWebhooks.ts
vendored
50
server/src/external/stripe/stripeWebhooks.ts
vendored
@@ -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,
|
||||
|
||||
@@ -316,6 +316,7 @@ export const handleCheckoutSessionCompleted = async ({
|
||||
: undefined
|
||||
: undefined,
|
||||
scenario: AttachScenario.New,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -206,6 +206,7 @@ export const handleSubscriptionUpdated = async ({
|
||||
},
|
||||
startsAt: fullSub.current_period_end * 1000,
|
||||
sendWebhook: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (fullCusProduct) {
|
||||
|
||||
49
server/src/external/stripe/webhookUtils/webhookUtils.ts
vendored
Normal file
49
server/src/external/stripe/webhookUtils/webhookUtils.ts
vendored
Normal 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 || [],
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
isProductUpgrade,
|
||||
} from "@/internal/products/productUtils.js";
|
||||
import { formatUnixToDate } from "@/utils/genUtils.js";
|
||||
|
||||
import {
|
||||
AppEnv,
|
||||
BillingType,
|
||||
Customer,
|
||||
Feature,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const handleCreateCheckout = async ({
|
||||
? {
|
||||
trial_end:
|
||||
freeTrial && !attachParams.disableFreeTrial
|
||||
? freeTrialToStripeTimestamp(freeTrial)
|
||||
? freeTrialToStripeTimestamp({ freeTrial })
|
||||
: undefined,
|
||||
// metadata: subMeta,
|
||||
billing_cycle_anchor: billingCycleAnchorUnixSeconds,
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -42,6 +42,7 @@ export const handleEntsChangedFunction = async ({
|
||||
disableFreeTrial: false,
|
||||
keepResetIntervals: true,
|
||||
carryExistingUsages,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info("✅ Successfully updated entitlements for product");
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(", ")}`,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
73
server/src/internal/customers/attach/handleAttach.ts
Normal file
73
server/src/internal/customers/attach/handleAttach.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AttachBranch } from "./AttachBranch.js";
|
||||
import { AttachBranch } from "@autumn/shared";
|
||||
|
||||
export enum ProrationBehavior {
|
||||
Immediately = "immediately",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ export const createNewCustomer = async ({
|
||||
? getNextStartOfMonthUnix(BillingInterval.Month)
|
||||
: undefined,
|
||||
scenario: AttachScenario.New,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AttachScenario,
|
||||
BillingType,
|
||||
CheckProductPreview,
|
||||
Customer,
|
||||
Feature,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
30
server/src/internal/products/productUtils/classifyProduct.ts
Normal file
30
server/src/internal/products/productUtils/classifyProduct.ts
Normal 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 });
|
||||
};
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
18
server/src/utils/formatUtils.ts
Normal file
18
server/src/utils/formatUtils.ts
Normal 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);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
21
shared/models/attachModels/attachEnums/AttachBranch.ts
Normal file
21
shared/models/attachModels/attachEnums/AttachBranch.ts
Normal 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",
|
||||
}
|
||||
15
shared/models/attachModels/attachEnums/AttachConfig.ts
Normal file
15
shared/models/attachModels/attachEnums/AttachConfig.ts
Normal 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;
|
||||
}
|
||||
15
shared/models/attachModels/attachEnums/AttachFunction.ts
Normal file
15
shared/models/attachModels/attachEnums/AttachFunction.ts
Normal 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.
|
||||
*/
|
||||
7
shared/models/attachModels/attachPreviewModels.ts
Normal file
7
shared/models/attachModels/attachPreviewModels.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AttachBranch } from "./attachEnums/AttachBranch.js";
|
||||
|
||||
export interface AttachPreview {
|
||||
branch: AttachBranch;
|
||||
options: any;
|
||||
new_items: any;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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${
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
21
vite/src/utils/productUtils.ts
Normal file
21
vite/src/utils/productUtils.ts
Normal 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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user