Merge branch 'staging' into feat/plan-editor

This commit is contained in:
John Yeo
2025-09-26 10:33:28 +01:00
40 changed files with 1017 additions and 792 deletions

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: AutumnInt is used for internal testing & scripts */
import dotenv from "dotenv";
dotenv.config();
@@ -50,12 +51,14 @@ export class AutumnInt {
baseUrl,
version,
orgConfig,
liveUrl = false,
}: {
apiKey?: string;
secretKey?: string;
baseUrl?: string;
version?: string | APIVersion;
orgConfig?: Partial<OrgConfig>;
liveUrl?: boolean;
} = {}) {
// this.apiKey = apiKey || process.env.AUTUMN_API_KEY || "";
this.apiKey =
@@ -74,7 +77,9 @@ export class AutumnInt {
this.headers["org-config"] = JSON.stringify(orgConfig);
}
this.baseUrl = baseUrl || "http://localhost:8080/v1";
this.baseUrl =
baseUrl ||
(liveUrl ? "https://api.useautumn.com/v1" : "http://localhost:8080/v1");
}
async get(path: string) {
@@ -91,13 +96,13 @@ export class AutumnInt {
body: JSON.stringify(body),
});
if (response.status != 200) {
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
message: `AutumnInt post request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}
@@ -127,13 +132,13 @@ export class AutumnInt {
},
);
if (response.status != 200) {
if (response.status !== 200) {
let error: any;
try {
error = await response.json();
} catch (error) {
throw new AutumnError({
message: "Failed to parse Autumn API error response",
message: `AutumnInt delete request failed, error: ${error}`,
code: ErrCode.InternalError,
});
}
@@ -181,11 +186,6 @@ export class AutumnInt {
async checkout(
params: CheckoutParams & { invoice?: boolean; force_checkout?: boolean },
) {
// const data = await this.post(`/attach`, {
// customer_id: customerId,
// product_id: productId,
// options: toSnakeCase(options),
// });
const data = await this.post(`/checkout`, params);
return data as CheckoutResult;
@@ -249,6 +249,13 @@ export class AutumnInt {
}
customers = {
list: async (params?: { limit?: number; offset?: number }) => {
const data = await this.get(
`/customers?${new URLSearchParams(params as Record<string, string>).toString()}`,
);
return data;
},
get: async (
customerId: string,
params?: {

View File

@@ -1,19 +1,21 @@
import Stripe from "stripe";
import {
BillingInterval,
type BillingInterval,
CusProductStatus,
Feature,
FullCusProduct,
Organization,
type Feature,
type FullCusProduct,
type Organization,
ProrationBehavior,
UsagePriceConfig,
} from "@autumn/shared";
import { differenceInSeconds } from "date-fns";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
// import { ProrationBehavior } from "@/internal/customers/change-product/handleUpgrade.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getEarliestPeriodEnd } from "./stripeSubUtils/convertSubUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
getEarliestPeriodEnd,
getLatestPeriodEnd,
} from "./stripeSubUtils/convertSubUtils.js";
export const getFullStripeSub = async ({
stripeCli,
@@ -52,15 +54,23 @@ export const getStripeSubs = async ({
}
};
for (const subId of subIds) {
const uniqueSubIds = Array.from(new Set(subIds));
for (const subId of uniqueSubIds) {
batchGet.push(getStripeSub(subId));
}
let subs = await Promise.all(batchGet);
subs = subs.filter((sub) => sub !== null);
// Sort by current_period_end (latest first)
subs.sort((a: any, b: any) => {
return b.current_period_end - a.current_period_end;
subs.sort((a, b) => {
if (!a || !b) {
return 0;
}
const aLatestPeriodEnd = getLatestPeriodEnd({ sub: a });
const bLatestPeriodEnd = getLatestPeriodEnd({ sub: b });
return bLatestPeriodEnd - aLatestPeriodEnd;
});
return subs as Stripe.Subscription[];
@@ -120,9 +130,9 @@ export const getUsageBasedSub = async ({
});
}
let finalSubIds = subs.map((sub) => sub.id);
const finalSubIds = subs.map((sub) => sub.id);
let autumnSubs = await SubService.getInStripeIds({
const autumnSubs = await SubService.getInStripeIds({
db,
ids: finalSubIds,
});
@@ -131,9 +141,9 @@ export const getUsageBasedSub = async ({
let usageFeatures: string[] | null = null;
// 1. Check if there's autumn sub
let autumnSub = autumnSubs?.find((sub) => sub.stripe_id == stripeSub.id);
const autumnSub = autumnSubs?.find((sub) => sub.stripe_id == stripeSub.id);
if (autumnSub) {
let containsFeature = autumnSub.usage_features.includes(
const containsFeature = autumnSub.usage_features.includes(
feature.internal_id!,
);
if (containsFeature) {
@@ -169,26 +179,25 @@ export const getSubItemsForCusProduct = async ({
stripeSub: Stripe.Subscription;
cusProduct: FullCusProduct;
}) => {
let prices = cusProduct.customer_prices.map((cp) => cp.price);
let product = cusProduct.product;
const prices = cusProduct.customer_prices.map((cp) => cp.price);
const product = cusProduct.product;
let subItems = [];
const subItems: Stripe.SubscriptionItem[] = [];
for (const item of stripeSub.items.data) {
if (item.price.product == product.processor?.id) {
if (item.price.product === product.processor?.id) {
subItems.push(item);
} else if (
prices.some(
(p) =>
p.config?.stripe_price_id == item.price.id ||
(p.config as UsagePriceConfig).stripe_product_id ==
item.price.product,
p.config.stripe_price_id === item.price.id ||
p.config.stripe_product_id === item.price.product,
)
) {
subItems.push(item);
}
}
let otherSubItems = stripeSub.items.data.filter(
(item) => !subItems.some((i) => i.id == item.id),
const otherSubItems = stripeSub.items.data.filter(
(item) => !subItems.some((i) => i.id === item.id),
);
return { subItems, otherSubItems };
@@ -244,7 +253,7 @@ export const getStripeSchedules = async ({
batchGet.push(getStripeSchedule(scheduleId));
}
let schedulesAndSubs = await Promise.all(batchGet);
const schedulesAndSubs = await Promise.all(batchGet);
return schedulesAndSubs.filter((schedule) => schedule !== null) as {
schedule: Stripe.SubscriptionSchedule;
@@ -287,7 +296,7 @@ export const getStripeProrationBehavior = ({
org: Organization;
prorationBehavior?: ProrationBehavior;
}) => {
let behaviourMap = {
const behaviourMap = {
[ProrationBehavior.Immediately]: "always_invoice",
[ProrationBehavior.NextBilling]: "create_prorations",
[ProrationBehavior.None]: "none",

View File

@@ -1,42 +1,41 @@
import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import {
getPriceEntitlement,
getEntOptions,
getProductForPrice,
} from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
FullProduct,
Price,
EntitlementWithFeature,
FeatureOptions,
Organization,
FullCusProduct,
type APIVersion,
type AttachConfig,
type AttachReplaceable,
BillingInterval,
Entity,
APIVersion,
InsertReplaceable,
AttachReplaceable,
type EntitlementWithFeature,
type Entity,
ErrCode,
AttachConfig,
ProductOptions,
type FeatureOptions,
type FullCusProduct,
type FullProduct,
isUsagePrice,
type Organization,
type Price,
type ProductOptions,
} from "@autumn/shared";
import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js";
import { getArrearItems } from "./getStripeSubItems/getArrearItems.js";
import {
compareBillingIntervals,
sortPricesByInterval,
} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import RecaseError from "@/utils/errorUtils.js";
import { logger } from "@/external/logtail/logtailUtils.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getExistingUsageFromCusProducts } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import {
intervalKeyToPrice,
priceToIntervalKey,
priceToProductOptions,
} from "@/internal/products/prices/priceUtils/convertPrice.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { ItemSet } from "@/utils/models/ItemSet.js";
import {
compareBillingIntervals,
sortPricesByInterval,
} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import {
getEntOptions,
getPriceEntitlement,
getProductForPrice,
} from "@/internal/products/prices/priceUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ItemSet } from "@/utils/models/ItemSet.js";
import { priceToStripeItem } from "../priceToStripeItem/priceToStripeItem.js";
import { getArrearItems } from "./getStripeSubItems/getArrearItems.js";
const getIntervalToPrices = (prices: Price[]) => {
const intervalToPrices: Record<string, Price[]> = {};
@@ -50,14 +49,14 @@ const getIntervalToPrices = (prices: Price[]) => {
intervalToPrices[key].push(price);
}
let oneOffPrices =
const oneOffPrices =
intervalToPrices[BillingInterval.OneOff] &&
intervalToPrices[BillingInterval.OneOff].length > 0;
// If there are multiple intervals, add one off prices to first interval
if (oneOffPrices && Object.keys(intervalToPrices).length > 1) {
const nextIntervalKey = Object.keys(intervalToPrices)[0];
intervalToPrices[nextIntervalKey!].push(
intervalToPrices[nextIntervalKey].push(
...structuredClone(intervalToPrices[BillingInterval.OneOff]),
);
delete intervalToPrices[BillingInterval.OneOff];
@@ -107,8 +106,8 @@ export const getStripeSubItems = async ({
for (const intervalKey in intervalToPrices) {
const prices = intervalToPrices[intervalKey];
let subItems: any[] = [];
let usage_features: any[] = [];
const subItems: any[] = [];
const usage_features: any[] = [];
for (const price of prices) {
const prodOptions = priceToProductOptions({
@@ -128,7 +127,7 @@ export const getStripeSubItems = async ({
internalEntityId,
});
let replaceables = priceEnt
const replaceables = priceEnt
? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id)
: [];
@@ -141,7 +140,7 @@ export const getStripeSubItems = async ({
});
}
let product = getProductForPrice(price, products)!;
const product = getProductForPrice(price, products);
if (!product) {
logger.error(
@@ -183,7 +182,7 @@ export const getStripeSubItems = async ({
}
const { interval, intervalCount } = intervalKeyToPrice(intervalKey);
if (subItems.length == 0) {
if (subItems.length === 0) {
subItems.push(
...getArrearItems({
prices,
@@ -260,13 +259,13 @@ export const getStripeSubItems2 = async ({
internalEntityId,
});
let replaceables = priceEnt
const replaceables = priceEnt
? attachParams.replaceables.filter((r) => r.ent.id === priceEnt.id)
: [];
existingUsage += replaceables.length;
let product = getProductForPrice(price, attachParams.products)!;
const product = getProductForPrice(price, attachParams.products)!;
if (!product) {
logger.error(

View File

@@ -1,17 +1,15 @@
import { cusProductToPrices } from "@autumn/shared";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { notNullish } from "@/utils/genUtils.js";
import {
BillingType,
FullCusProduct,
Organization,
Price,
prices,
type BillingType,
cusProductToPrices,
type FullCusProduct,
type Price,
PriceType,
UsagePriceConfig,
type UsagePriceConfig,
} from "@autumn/shared";
import Stripe from "stripe";
import type Stripe from "stripe";
import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
const autumnStripePricesMatch = ({
stripePrice,
@@ -24,16 +22,16 @@ const autumnStripePricesMatch = ({
}) => {
const config = autumnPrice.config as UsagePriceConfig;
if (config.type == PriceType.Fixed) {
if (config.type === PriceType.Fixed) {
return (
config.stripe_price_id == stripePrice.id ||
(stripeProdId && stripePrice.product == stripeProdId)
config.stripe_price_id === stripePrice.id ||
(stripeProdId && stripePrice.product === stripeProdId)
);
} else {
return (
config.stripe_price_id == stripePrice.id ||
config.stripe_product_id == stripePrice.product ||
config.stripe_empty_price_id == stripePrice.id
config.stripe_price_id === stripePrice.id ||
config.stripe_product_id === stripePrice.product ||
config.stripe_empty_price_id === stripePrice.id
);
}
};
@@ -79,7 +77,7 @@ export const findStripeItemForPrice = ({
}) => {
if (invoiceLineItems) {
return invoiceLineItems.find((li) => {
return li.pricing?.price_details?.price == price.config.stripe_price_id;
return li.pricing?.price_details?.price === price.config.stripe_price_id;
});
}
@@ -89,9 +87,9 @@ export const findStripeItemForPrice = ({
const config = price.config as UsagePriceConfig;
return (
config.stripe_price_id == si.price?.id ||
config.stripe_product_id == si.price?.product ||
config.stripe_empty_price_id == si.price?.id
config.stripe_price_id === si.price?.id ||
config.stripe_product_id === si.price?.product ||
config.stripe_empty_price_id === si.price?.id
);
},
);
@@ -105,31 +103,14 @@ export const findStripeItemForPrice = ({
const config = price.config as UsagePriceConfig;
return (
config.stripe_price_id == si.price?.id ||
(stripeProdId && si.price?.product == stripeProdId)
config.stripe_price_id === si.price?.id ||
(stripeProdId && si.price?.product === stripeProdId)
);
},
);
}
return undefined;
// return stripeItems.find((si: Stripe.SubscriptionItem | Stripe.LineItem) => {
// const config = price.config as UsagePriceConfig;
// if (config.type == PriceType.Fixed) {
// return (
// config.stripe_price_id == si.price?.id ||
// (stripeProdId && si.price?.product == stripeProdId)
// );
// } else {
// return (
// config.stripe_price_id == si.price?.id ||
// config.stripe_product_id == si.price?.product ||
// config.stripe_empty_price_id == si.price?.id
// );
// }
// });
}
};
@@ -145,24 +126,26 @@ export const findPriceInStripeItems = ({
billingType?: BillingType;
}) => {
return prices.find((p: Price) => {
let config = p.config;
const config = p.config;
let itemMatch;
let itemMatch: boolean = false;
if (subItem) {
itemMatch =
config.stripe_price_id == subItem.price?.id ||
config.stripe_product_id == subItem.price?.product;
config.stripe_price_id === subItem.price?.id ||
config.stripe_product_id === subItem.price?.product;
}
if (lineItem) {
const priceDetails = lineItem.pricing?.price_details;
itemMatch =
config.stripe_price_id == priceDetails?.price ||
config.stripe_product_id == priceDetails?.product;
config.stripe_price_id === priceDetails?.price ||
config.stripe_product_id === priceDetails?.product;
}
const priceBillingType = getBillingType(config);
let billingTypeMatch = billingType ? priceBillingType == billingType : true;
const billingTypeMatch = billingType
? priceBillingType === billingType
: true;
return itemMatch && billingTypeMatch;
});
@@ -190,14 +173,14 @@ export const lineItemInCusProduct = ({
cusProduct: FullCusProduct;
lineItem: Stripe.InvoiceLineItem;
}) => {
let stripeProdId = cusProduct.product.processor?.id;
const stripeProdId = cusProduct.product.processor?.id;
let prices = cusProductToPrices({ cusProduct });
let price = findPriceInStripeItems({ prices, lineItem });
const prices = cusProductToPrices({ cusProduct });
const price = findPriceInStripeItems({ prices, lineItem });
const priceDetails = lineItem.pricing?.price_details;
return stripeProdId == priceDetails?.product || notNullish(price);
return stripeProdId === priceDetails?.product || notNullish(price);
};
export const subItemInCusProduct = ({
@@ -207,12 +190,12 @@ export const subItemInCusProduct = ({
cusProduct: FullCusProduct;
subItem: Stripe.SubscriptionItem;
}) => {
let stripeProdId = cusProduct.product.processor?.id;
const stripeProdId = cusProduct.product.processor?.id;
let prices = cusProductToPrices({ cusProduct });
let price = findPriceInStripeItems({ prices, subItem });
const prices = cusProductToPrices({ cusProduct });
const price = findPriceInStripeItems({ prices, subItem });
return stripeProdId == subItem.price.product || notNullish(price);
return stripeProdId === subItem.price.product || notNullish(price);
};
export const scheduleItemToPrice = ({
@@ -247,10 +230,10 @@ export const scheduleItemInCusProduct = ({
cusProduct: FullCusProduct;
scheduleItem: Stripe.SubscriptionSchedule.Phase.Item;
}) => {
let stripeProdId = cusProduct.product.processor?.id;
const stripeProdId = cusProduct.product.processor?.id;
let autumnPrices = cusProductToPrices({ cusProduct });
let price = autumnPrices.find((p) => {
const autumnPrices = cusProductToPrices({ cusProduct });
const price = autumnPrices.find((p) => {
const stripePrice = scheduleItem.price as Stripe.Price;
return autumnStripePricesMatch({
@@ -268,7 +251,7 @@ export const isLicenseItem = ({
}: {
stripeItem: Stripe.SubscriptionItem | Stripe.LineItem;
}) => {
return stripeItem.price?.recurring?.usage_type == "licensed";
return stripeItem.price?.recurring?.usage_type === "licensed";
};
export const isMeteredItem = ({
@@ -276,7 +259,7 @@ export const isMeteredItem = ({
}: {
stripeItem: Stripe.SubscriptionItem | Stripe.LineItem;
}) => {
return stripeItem.price?.recurring?.usage_type == "metered";
return stripeItem.price?.recurring?.usage_type === "metered";
};
// Get sub item from product

View File

@@ -1,8 +1,7 @@
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { APIVersion, Organization, UsagePriceConfig } from "@autumn/shared";
import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { APIVersion, isUsagePrice, type Organization } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { getEmptyPriceItem } from "../../priceToStripeItem/priceToStripeItem.js";
export const handleRemainingSets = async ({
@@ -23,10 +22,10 @@ export const handleRemainingSets = async ({
logger: any;
}) => {
const itemSets = attachParams.itemSets;
let remainingSets = itemSets ? itemSets.slice(1) : [];
const remainingSets = itemSets ? itemSets.slice(1) : [];
const remainingItems = remainingSets.flatMap((set) => set.items);
let invoiceIds: string[] = checkoutSession.invoice
const invoiceIds: string[] = checkoutSession.invoice
? [checkoutSession.invoice as string]
: [];
@@ -34,18 +33,18 @@ export const handleRemainingSets = async ({
for (const price of attachParams.prices) {
if (!isUsagePrice({ price })) continue;
const config = price.config as UsagePriceConfig;
const config = price.config;
const emptyPrice = config.stripe_empty_price_id;
if (
attachParams.internalEntityId ||
attachParams.apiVersion == APIVersion.v1_4
attachParams.apiVersion === APIVersion.v1_4
) {
const replaceIndex = remainingItems.findIndex(
(item) => item.price == config.stripe_price_id,
(item) => item.price === config.stripe_price_id,
);
if (replaceIndex != -1) {
if (replaceIndex !== -1) {
remainingItems[replaceIndex] = emptyPrice
? {
price: config.stripe_empty_price_id,

View File

@@ -2,6 +2,7 @@
import { ErrCode, type FullCustomer } from "@autumn/shared";
import type { ClickHouseClient } from "@clickhouse/client";
import { Decimal } from "decimal.js";
import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
@@ -302,7 +303,7 @@ order by dr.period;
resultJson.data.forEach((row: any) => {
Object.keys(row).forEach((key: string) => {
if (key !== "period") {
row[key] = parseInt(row[key]);
row[key] = new Decimal(row[key]).toDecimalPlaces(10).toNumber();
}
});
});

View File

@@ -1,21 +1,19 @@
import {
ErrCode,
type Feature,
FeatureType,
type FullCusProduct,
type FullCustomer,
} from "@autumn/shared";
import { Router } from "express";
import { StatusCodes } from "http-status-codes";
import { CacheType } from "@/external/caching/cacheActions.js";
import { queryWithCache } from "@/external/caching/cacheUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusService } from "../customers/CusService.js";
import { AnalyticsService } from "./AnalyticsService.js";
import { StatusCodes } from "http-status-codes";
import {
AppEnv,
ErrCode,
Feature,
FeatureType,
FullCusProduct,
FullCustomer,
Organization,
} from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { queryWithCache } from "@/external/caching/cacheUtils.js";
import { CacheType } from "@/external/caching/cacheActions.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
export const analyticsRouter = Router();
@@ -28,7 +26,7 @@ analyticsRouter.get("/event_names", async (req: any, res: any) =>
const { db, org, env, features } = req;
const { interval, event_names, customer_id } = req.body;
let result = await queryWithCache({
const result = await queryWithCache({
action: CacheType.TopEvents,
key: `${org.id}_${env}`,
fn: async () => {
@@ -46,15 +44,15 @@ analyticsRouter.get("/event_names", async (req: any, res: any) =>
// let result = topEventNamesRes?.eventNames;
let featureIds: string[] = [];
let eventNames: string[] = [];
const featureIds: string[] = [];
const eventNames: string[] = [];
for (let i = 0; i < result.length; i++) {
// Is an event name
if (
features.some(
(feature: Feature) =>
feature.type == FeatureType.Metered &&
feature.type === FeatureType.Metered &&
feature.config.filters?.[0]?.value.includes(result[i]),
)
) {
@@ -95,17 +93,17 @@ const getTopEvents = async ({ req }: { req: ExtendedRequest }) => {
req,
});
let result = topEventNamesRes?.eventNames;
const result = topEventNamesRes?.eventNames;
let featureIds: string[] = [];
let eventNames: string[] = [];
const featureIds: string[] = [];
const eventNames: string[] = [];
for (let i = 0; i < result.length; i++) {
// Is an event name
if (
features.some(
(feature: Feature) =>
feature.type == FeatureType.Metered &&
feature.type === FeatureType.Metered &&
feature.config.filters?.[0]?.value.includes(result[i]),
)
) {
@@ -131,9 +129,7 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
const { db, org, env, features } = req;
let { interval, event_names, customer_id } = req.body;
let topEvents:
| { featureIds: string[]; eventNames: string[] }
| undefined = undefined;
let topEvents: { featureIds: string[]; eventNames: string[] } | undefined;
if (!event_names || event_names.length === 0) {
topEvents = await getTopEvents({ req });
@@ -141,7 +137,7 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
}
let aggregateAll = false;
let customer: FullCustomer | undefined = undefined;
let customer: FullCustomer | undefined;
let bcExclusionFlag = false;
if (!customer_id) {
@@ -190,8 +186,6 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
aggregateAll,
});
// console.log("events", events);
res.status(200).json({
customer,
events,
@@ -214,7 +208,7 @@ analyticsRouter.post("/raw", async (req: any, res: any) =>
const { interval, customer_id } = req.body;
let aggregateAll = false;
let customer: FullCustomer | undefined = undefined;
let customer: FullCustomer | undefined;
if (!customer_id) {
// No customer ID provided, set aggregateAll to true

View File

@@ -1,19 +1,23 @@
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { fullCusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {
isOneOff,
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import { sortProductsByPrice } from "@/internal/products/productUtils/sortProductUtils.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { notNullish } from "@/utils/genUtils.js";
import {
type Feature,
FeaturePreviewScenario,
type FullCusProduct,
type FullEntitlement,
type FullProduct,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { fullCusProductToProduct } from "@/internal/customers/cusProducts/cusProductUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { getProductResponse } from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import {
sortFullProducts,
sortProductsByPrice,
} from "@/internal/products/productUtils/sortProductUtils.js";
import {
isOneOff,
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import { notNullish } from "@/utils/genUtils.js";
export const getCheckPreview = async ({
db,
@@ -34,30 +38,34 @@ export const getCheckPreview = async ({
return null;
}
let mainCusProds = cusProducts.filter(
const mainCusProds = cusProducts.filter(
(cp: FullCusProduct) => !cp.product.is_add_on,
);
let cusOwnedProducts = mainCusProds.map((cp: FullCusProduct) =>
const cusOwnedProducts = mainCusProds.map((cp: FullCusProduct) =>
fullCusProductToProduct(cp),
);
sortProductsByPrice({ products: cusOwnedProducts });
let highestTierProd =
const highestTierProd =
cusOwnedProducts.length > 0 ? cusOwnedProducts[0] : null;
let products: FullProduct[] = await ProductService.getByFeature({
const products: FullProduct[] = await ProductService.getByFeature({
db,
internalFeatureId: feature.internal_id!,
internalFeatureId: feature.internal_id,
});
sortFullProducts({ products });
// 1. Get add ons
let addOns = [];
for (let addOn of products) {
const addOns = [];
for (const addOn of products) {
if (addOn.is_add_on) {
if (isOneOff(addOn.prices)) {
addOns.push(addOn);
} else if (
!cusProducts.some((cp: FullCusProduct) => cp.product.id == addOn.id)
!cusProducts.some((cp: FullCusProduct) => cp.product.id === addOn.id)
) {
addOns.push(addOn);
}
@@ -68,12 +76,13 @@ export const getCheckPreview = async ({
if (!highestTierProd) {
mainProds = products.filter((product: FullProduct) => !product.is_add_on);
} else {
for (let prod of products) {
for (const prod of products) {
if (prod.is_add_on) {
continue;
}
if (mainCusProds.some((cp: FullCusProduct) => cp.product.id == prod.id)) {
continue;
if (
mainCusProds.some((cp: FullCusProduct) => cp.product.id === prod.id)
) {
} else if (
isProductUpgrade({
prices1: highestTierProd.prices,
@@ -86,21 +95,21 @@ export const getCheckPreview = async ({
}
}
let rawProducts = [...mainProds, ...addOns];
for (let p of rawProducts) {
const rawProducts = [...mainProds, ...addOns];
for (const p of rawProducts) {
p.entitlements = p.entitlements.map((e) => ({
...e,
feature: allFeatures.find((f) => f.id == e.feature_id)!,
}));
feature: allFeatures.find((f) => f.id === e.feature_id),
})) as FullEntitlement[];
}
let v2Prods = await Promise.all(
const v2Prods = await Promise.all(
rawProducts.map((p) =>
getProductResponse({ product: p, features: allFeatures }),
),
);
let scenario = notNullish(balance)
const scenario = notNullish(balance)
? FeaturePreviewScenario.UsageLimit
: FeaturePreviewScenario.FeatureFlag;
@@ -111,7 +120,7 @@ export const getCheckPreview = async ({
feature_id: feature.id,
feature_name: feature.name,
message:
scenario == FeaturePreviewScenario.UsageLimit
scenario === FeaturePreviewScenario.UsageLimit
? `You have reached the usage limit for ${feature.name}. Please contact us to increase your limit.`
: `${feature.name} is not available for your account. Please contact us to enable it.`,
@@ -120,9 +129,9 @@ export const getCheckPreview = async ({
};
}
let nextProd = mainProds.length > 0 ? mainProds[0] : addOns[0];
const nextProd = mainProds.length > 0 ? mainProds[0] : addOns[0];
let title = nextProd.free_trial
const title = nextProd.free_trial
? `Start trial for ${nextProd.name}`
: !nextProd.is_add_on
? `Upgrade to ${nextProd.name}`
@@ -140,7 +149,7 @@ export const getCheckPreview = async ({
}
msg = `${msg} ${prodString}`;
} else if (addOns.length > 0) {
let prodString = `Please purchase the ${addOns[0].name} add on to continue using this feature.`;
const prodString = `Please purchase the ${addOns[0].name} add on to continue using this feature.`;
msg = `${msg} ${prodString}`;
}
}
@@ -155,12 +164,12 @@ export const getCheckPreview = async ({
}
msg = `${msg} ${prodString}`;
} else if (addOns.length > 0) {
let prodString = `Please purchase the ${addOns[0].name} add on to use this feature.`;
const prodString = `Please purchase the ${addOns[0].name} add on to use this feature.`;
msg = `${msg} ${prodString}`;
}
}
let nextTier =
const nextTier =
mainProds.length > 0 ? mainProds[0] : addOns.length > 0 ? addOns[0] : null;
return {

View File

@@ -1,18 +1,18 @@
import {
ErrCode,
nullish,
type RewardProgram,
RewardTriggerEvent,
UpdateRewardProgram,
} from "@autumn/shared";
import express, { type Router } from "express";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import {
handleCreateRewardProgram,
handleDeleteRewardProgram,
} from "./handlers/rewardPrograms/index.js";
import { routeHandler } from "@/utils/routerUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import {
CreateRewardProgram,
ErrCode,
nullish,
RewardTriggerEvent,
} from "@autumn/shared";
import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js";
import { constructRewardProgram } from "@/internal/rewards/rewardTriggerUtils.js";
export const rewardProgramRouter: Router = express.Router();
@@ -39,7 +39,7 @@ rewardProgramRouter.put("/:id", (req, res) =>
}
// Ensure program exists
let existingProgram = await RewardProgramService.get({
const existingProgram = await RewardProgramService.get({
db,
idOrInternalId: id,
orgId,
@@ -54,21 +54,22 @@ rewardProgramRouter.put("/:id", (req, res) =>
});
}
const rewardProgram = constructRewardProgram({
rewardProgramData: CreateRewardProgram.parse({
...body,
id: existingProgram.id, // ID cannot be changed
}),
orgId,
env,
});
// const rewardProgram = constructRewardProgram({
// rewardProgramData: CreateRewardProgram.parse({
// ...body,
// id: existingProgram.id, // ID cannot be changed
// }),
// orgId,
// env,
// });
// Update on existing redemptions? (should be none unless affecting stacked rewards...)
const data = UpdateRewardProgram.parse(body);
if (
rewardProgram.when == RewardTriggerEvent.Checkout &&
(nullish(rewardProgram.product_ids) ||
rewardProgram.product_ids!.length == 0)
data.when === RewardTriggerEvent.Checkout &&
(nullish(data.product_ids) || data.product_ids.length === 0)
) {
throw new RecaseError({
message:
@@ -78,12 +79,12 @@ rewardProgramRouter.put("/:id", (req, res) =>
});
}
let updatedRewardProgram = await RewardProgramService.update({
const updatedRewardProgram = await RewardProgramService.update({
db,
idOrInternalId: id,
orgId,
env,
data: rewardProgram,
data: data as RewardProgram,
});
return res.status(200).json(updatedRewardProgram);

View File

@@ -22,8 +22,8 @@ import {
import RecaseError from "@/utils/errorUtils.js";
import {
attachParamsToCurCusProduct,
getCustomerSchedule,
getCustomerSub,
paramsToCurSubSchedule,
} from "../../attachUtils/convertAttachParams.js";
import { paramsToScheduleItems } from "../../mergeUtils/paramsToScheduleItems.js";
import { getCurrentPhaseIndex } from "../../mergeUtils/phaseUtils/phaseUtils.js";
@@ -54,7 +54,12 @@ export const handleScheduleFunction2 = async ({
const { sub: curSub } = await getCustomerSub({ attachParams });
// 1. Cancel current subscription and fetch items from other cus products...?
let schedule = await paramsToCurSubSchedule({ attachParams });
let { schedule } = await getCustomerSchedule({
attachParams,
subId: curSub?.id,
logger,
});
if (!curSub) {
throw new RecaseError({
message: `SCHEDULE FLOW, curSub is undefined`,

View File

@@ -226,8 +226,8 @@ export const paramsToCurSub = async ({
}) => {
const { stripeCli } = attachParams;
const curCusProduct = attachParamsToCurCusProduct({ attachParams });
console.log("Cur cus product:", curCusProduct);
console.log("Sub IDs:", curCusProduct?.subscription_ids);
// console.log("Cur cus product:", curCusProduct);
// console.log("Sub IDs:", curCusProduct?.subscription_ids);
const subIds = curCusProduct?.subscription_ids || [];

View File

@@ -1,17 +1,13 @@
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import {
isArrearPrice,
isContUsePrice,
type AttachBody,
type AttachBranch,
isUsagePrice,
} from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import RecaseError from "@/utils/errorUtils.js";
import {
AttachBody,
AttachBranch,
notNullish,
nullish,
Price,
type Price,
} from "@autumn/shared";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import RecaseError from "@/utils/errorUtils.js";
export const handleMultiAttachErrors = async ({
attachParams,

View File

@@ -1,29 +1,32 @@
import { AttachFunction, FeatureOptions } from "@autumn/shared";
import {
type AttachBody,
AttachBodySchema,
AttachFunction,
type FeatureOptions,
} from "@autumn/shared";
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { AttachBody, AttachBodySchema } from "@autumn/shared";
import { ExtendedResponse } from "@/utils/models/Request.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { handleCreateCheckout } from "../../add-product/handleCreateCheckout.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
checkStripeConnections,
handlePrepaidErrors,
} from "../attachRouter.js";
import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js";
import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { getAttachParams } from "../attachUtils/attachParams/getAttachParams.js";
import { attachParamsToProduct } from "../attachUtils/convertAttachParams.js";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { priceToFeature } from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getPriceOptions } from "@/internal/products/prices/priceUtils.js";
import { getAttachBranch } from "../attachUtils/getAttachBranch.js";
import { getAttachConfig } from "../attachUtils/getAttachConfig.js";
import { getAttachFunction } from "../attachUtils/getAttachFunction.js";
import { attachParamsToPreview } from "../handleAttachPreview/attachParamsToPreview.js";
import { getHasProrations } from "./getHasProrations.js";
import { handleCreateInvoiceCheckout } from "../../add-product/handleCreateInvoiceCheckout.js";
import { z } from "zod";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import { previewToCheckoutRes } from "./previewToCheckoutRes.js";
const getAttachVars = async ({
req,
@@ -79,13 +82,15 @@ const getCheckoutOptions = async ({
isPrepaidPrice({ price: p }),
);
let newOptions: FeatureOptions[] = structuredClone(attachParams.optionsList);
const newOptions: FeatureOptions[] = structuredClone(
attachParams.optionsList,
);
for (const prepaidPrice of prepaidPrices) {
const feature = priceToFeature({
price: prepaidPrice,
features: req.features,
});
let option = getPriceOptions(prepaidPrice, attachParams.optionsList);
const option = getPriceOptions(prepaidPrice, attachParams.optionsList);
if (!option) {
newOptions.push({
feature_id: feature?.id!,
@@ -116,7 +121,7 @@ export const handleCheckout = (req: any, res: any) =>
let checkoutUrl = null;
if (func == AttachFunction.CreateCheckout) {
if (func === AttachFunction.CreateCheckout) {
await checkStripeConnections({
req,
attachParams,
@@ -182,17 +187,9 @@ export const handleCheckout = (req: any, res: any) =>
attachParams,
});
if (checkoutRes.next_cycle) {
const nextCycle = checkoutRes.next_cycle;
}
res.status(200).json({
...checkoutRes,
url: checkoutUrl,
options: attachParams.optionsList.map((o) => ({
quantity: o.quantity,
feature_id: o.feature_id,
})),
has_prorations: hasProrations,
});

View File

@@ -1,31 +1,30 @@
import {
AttachBranch,
AttachPreview,
CheckoutLine,
type AttachBranch,
type AttachPreview,
type CheckoutLine,
CheckoutResponseSchema,
UsageModel,
} from "@autumn/shared";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
import {
cusProductToEnts,
cusProductToPrices,
cusProductToProduct,
isUsagePrice,
type PreviewLineItem,
toProductItem,
UsageModel,
} from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { Decimal } from "decimal.js";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import {
getProductItemResponse,
getProductResponse,
} from "@/internal/products/productUtils/productResponseUtils/getProductResponse.js";
import { toProductItem } from "@autumn/shared";
import { getPriceEntitlement } from "@/internal/products/prices/priceUtils.js";
import { formatUnixToDateTime, notNullish } from "@/utils/genUtils.js";
import { isPriceItem } from "@/internal/products/product-items/productItemUtils/getItemType.js";
import { Decimal } from "decimal.js";
import { notNullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
} from "../attachUtils/convertAttachParams.js";
export const previewToCheckoutRes = async ({
req,
@@ -42,29 +41,29 @@ export const previewToCheckoutRes = async ({
const product = attachParamsToProduct({ attachParams });
const { curCusProduct } = attachParamToCusProducts({ attachParams });
let curPrices = curCusProduct
const curPrices = curCusProduct
? cusProductToPrices({ cusProduct: curCusProduct })
: [];
let curEnts = curCusProduct
const curEnts = curCusProduct
? cusProductToEnts({ cusProduct: curCusProduct })
: [];
let newPrices = attachParams.prices;
let newEnts = attachParams.entitlements;
let allPrices = [...curPrices, ...newPrices];
let allEnts = [...curEnts, ...newEnts];
const newPrices = attachParams.prices;
const newEnts = attachParams.entitlements;
const allPrices = [...curPrices, ...newPrices];
const allEnts = [...curEnts, ...newEnts];
let lines: CheckoutLine[] = [];
if (preview.due_today && preview.due_today.line_items.length > 0) {
lines = preview.due_today.line_items
.map((li: any) => {
let price = allPrices.find((p) => p.id == li.price_id);
.map((li: PreviewLineItem) => {
const price = allPrices.find((p) => p.id === li.price_id);
if (!price) {
return null;
}
let ent = getPriceEntitlement(price, allEnts);
const ent = getPriceEntitlement(price, allEnts);
return {
description: li.description || "",
@@ -89,7 +88,7 @@ export const previewToCheckoutRes = async ({
fullCus: attachParams.customer,
});
let curProduct = curCusProduct
const curProduct = curCusProduct
? await getProductResponse({
product: cusProductToProduct({ cusProduct: curCusProduct }),
features,
@@ -100,7 +99,12 @@ export const previewToCheckoutRes = async ({
const total = lines.reduce((acc, line) => acc + line.amount, 0);
let nextCycle = undefined;
let nextCycle:
| {
starts_at: number;
total: number;
}
| undefined;
if (
notNullish(preview.due_next_cycle) &&
@@ -108,7 +112,7 @@ export const previewToCheckoutRes = async ({
) {
let total = newProduct.items
.reduce((acc, item) => {
if (item.usage_model == UsageModel.PayPerUse) {
if (item.usage_model === UsageModel.PayPerUse) {
return acc;
}
@@ -116,8 +120,8 @@ export const previewToCheckoutRes = async ({
return acc.plus(item.price || 0);
}
let prepaidQuantity =
attachParams.optionsList.find((o) => o.feature_id == item.feature_id)
const prepaidQuantity =
attachParams.optionsList.find((o) => o.feature_id === item.feature_id)
?.quantity || 0;
return acc.plus(prepaidQuantity * (item.price || 0));
@@ -126,8 +130,7 @@ export const previewToCheckoutRes = async ({
try {
if (
preview.due_next_cycle &&
preview.due_next_cycle.line_items &&
preview.due_next_cycle?.line_items &&
preview.due_next_cycle.line_items.length > 0
) {
total = preview.due_next_cycle.line_items
@@ -148,6 +151,28 @@ export const previewToCheckoutRes = async ({
};
}
// Options
const options = attachParams.optionsList
.map((o) => {
const price = allPrices.find((p) => {
if (isUsagePrice({ price: p })) {
return (
p.config.internal_feature_id === o.internal_feature_id ||
p.config.feature_id === o.feature_id
);
}
return false;
});
if (!price) return undefined;
return {
feature_id: o.feature_id,
quantity: o.quantity * (price.config.billing_units || 1),
};
})
.filter(notNullish);
return CheckoutResponseSchema.parse({
customer_id: attachParams.customer.id,
lines,
@@ -159,5 +184,6 @@ export const previewToCheckoutRes = async ({
? preview.due_next_cycle.due_at
: null,
next_cycle: nextCycle,
options,
});
};

View File

@@ -1,25 +1,24 @@
import {
AttachBranch,
AttachConfig,
type AttachConfig,
BillingInterval,
FullProduct,
FreeTrial,
type FullProduct,
isTrialing,
} from "@autumn/shared";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { AttachParams } from "../../cusProducts/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
getCustomerSub,
} from "../attachUtils/convertAttachParams.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingIntervalUtils.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isTrialing } from "@autumn/shared";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import { formatUnixToDate } from "@/utils/genUtils.js";
// Just for new product...?
const getNextCycleItems = async ({
newProduct,
attachParams,
@@ -40,7 +39,8 @@ const getNextCycleItems = async ({
trialEnds?: number | null;
}) => {
// 2. If free trial
let nextCycleAt = undefined;
let nextCycleAt: number | undefined;
if (attachParams.freeTrial) {
if (trialEnds) {
nextCycleAt = trialEnds;
@@ -51,7 +51,7 @@ const getNextCycleItems = async ({
now: attachParams.now,
})! * 1000;
}
} else if (branch != AttachBranch.OneOff && anchor) {
} else if (branch !== AttachBranch.OneOff && anchor) {
// Yearly one
const largestInterval = getLargestInterval({ prices: newProduct.prices });
if (largestInterval) {
@@ -69,7 +69,7 @@ const getNextCycleItems = async ({
now: attachParams.now,
logger,
withPrepaid,
anchor,
// anchor,
});
return {
@@ -98,14 +98,14 @@ export const getNewProductPreview = async ({
attachParams,
});
let trialEnds = undefined;
let trialEnds: number | undefined;
// Scenario where we update a current sub with new product (so no create sub)
let anchor = undefined;
let anchor: number | undefined;
if (mergeSub && !config.disableMerge) {
if (mergeCusProduct?.free_trial) {
if (isTrialing({ cusProduct: mergeCusProduct, now: attachParams.now })) {
trialEnds = mergeCusProduct.trial_ends_at;
trialEnds = mergeCusProduct.trial_ends_at || undefined;
attachParams.freeTrial = mergeCusProduct.free_trial;
} else {
attachParams.freeTrial = null;
@@ -141,7 +141,7 @@ export const getNewProductPreview = async ({
trialEnds,
});
let options = getOptions({
const options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,

View File

@@ -1,42 +1,38 @@
import { AttachParams } from "../../cusProducts/AttachParams.js";
import {
AttachBranch,
type AttachConfig,
cusProductToPrices,
cusProductToProduct,
type FreeTrial,
type FullCusProduct,
isTrialing,
OnDecrease,
OnIncrease,
type PreviewLineItem,
type Price,
UsageModel,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import { nullish } from "@/utils/genUtils.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import type { AttachParams } from "../../cusProducts/AttachParams.js";
import {
attachParamsToProduct,
attachParamToCusProducts,
paramsToCurSub,
} from "../attachUtils/convertAttachParams.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { getLargestInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
import { mapToProductItems } from "@/internal/products/productV2Utils.js";
import Stripe from "stripe";
import {
AttachBranch,
FreeTrial,
FullCusProduct,
PreviewLineItem,
Price,
UsageModel,
AttachConfig,
UsagePriceConfig,
OnDecrease,
OnIncrease,
} from "@autumn/shared";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { Decimal } from "decimal.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { formatUnixToDate, nullish } from "@/utils/genUtils.js";
import { isTrialing } from "@autumn/shared";
import { cusProductToPrices } from "@autumn/shared";
import { isPrepaidPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
addIntervalToAnchor,
getAlignedUnix,
} from "@/internal/products/prices/billingIntervalUtils2.js";
const getNextCycleAt = ({
prices,
sub,
@@ -46,7 +42,7 @@ const getNextCycleAt = ({
curCusProduct,
}: {
prices: Price[];
sub: Stripe.Subscription;
sub?: Stripe.Subscription;
now?: number;
freeTrial?: FreeTrial | null;
branch: AttachBranch;
@@ -55,7 +51,7 @@ const getNextCycleAt = ({
now = now || Date.now();
if (
branch == AttachBranch.NewVersion &&
branch === AttachBranch.NewVersion &&
curCusProduct &&
isTrialing({ cusProduct: curCusProduct, now })
) {
@@ -72,7 +68,7 @@ const getNextCycleAt = ({
}
const largestInterval = getLargestInterval({ prices });
if (nullish(largestInterval) || !sub.billing_cycle_anchor) return now;
if (nullish(largestInterval) || !sub?.billing_cycle_anchor) return now;
const nextCycleAt = getAlignedUnix({
anchor: sub.billing_cycle_anchor * 1000,
@@ -101,17 +97,17 @@ const filterNoProratePrepaidItems = ({
for (const option of attachParams.optionsList) {
const { feature_id, internal_feature_id, quantity } = option;
const prevQuantity = curSameProduct?.options.find(
(o) => o.feature_id == feature_id,
(o) => o.feature_id === feature_id,
)?.quantity;
const curPrice = curPrices.find(
(p) =>
(p.config as UsagePriceConfig)?.internal_feature_id ==
(p.config as UsagePriceConfig)?.internal_feature_id ===
internal_feature_id && isPrepaidPrice({ price: p }),
);
const onDecrease = curPrice?.proration_config?.on_decrease;
const decreaseIsNone = onDecrease == OnDecrease.None;
const decreaseIsNone = onDecrease === OnDecrease.None;
if (decreaseIsNone && prevQuantity && quantity < prevQuantity) {
console.log(
@@ -122,7 +118,7 @@ const filterNoProratePrepaidItems = ({
const onIncrease = curPrice?.proration_config?.on_increase;
if (
onIncrease == OnIncrease.ProrateNextCycle &&
onIncrease === OnIncrease.ProrateNextCycle &&
prevQuantity &&
quantity > prevQuantity
) {
@@ -173,7 +169,7 @@ export const getUpgradeProductPreview = async ({
if (config?.disableTrial) attachParams.freeTrial = null;
let freeTrial = attachParams.freeTrial;
let anchor = sub ? sub.billing_cycle_anchor * 1000 : undefined;
const anchor = sub ? sub.billing_cycle_anchor * 1000 : undefined;
if (
config?.carryTrial &&
@@ -194,7 +190,9 @@ export const getUpgradeProductPreview = async ({
anchor,
});
let dueNextCycle = undefined;
let dueNextCycle:
| { line_items: PreviewLineItem[]; due_at: number }
| undefined;
if (!isFreeProduct(newProduct.prices)) {
const nextCycleAt = getNextCycleAt({
prices: newProduct.prices,
@@ -205,31 +203,33 @@ export const getUpgradeProductPreview = async ({
curCusProduct,
});
let nextCycleItems = await getItemsForNewProduct({
const nextCycleItems = await getItemsForNewProduct({
newProduct,
attachParams,
logger,
withPrepaid,
});
dueNextCycle = {
line_items: nextCycleItems,
due_at: nextCycleAt,
};
if (nextCycleAt) {
dueNextCycle = {
line_items: nextCycleItems,
due_at: nextCycleAt,
};
}
}
let items = [...curPreviewItems, ...newPreviewItems];
for (const item of structuredClone(curPreviewItems)) {
let priceId = item.price_id;
let newItem = newPreviewItems.find((i) => i.price_id == priceId);
const priceId = item.price_id;
const newItem = newPreviewItems.find((i) => i.price_id === priceId);
if (!newItem) {
continue;
}
let newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2);
let curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2);
const newItemAmount = new Decimal(newItem?.amount ?? 0).toDecimalPlaces(2);
const curItemAmount = new Decimal(item.amount ?? 0).toDecimalPlaces(2);
if (newItemAmount.add(curItemAmount).eq(0)) {
items = items.filter((i) => i.price_id !== priceId);
@@ -241,7 +241,7 @@ export const getUpgradeProductPreview = async ({
.toDecimalPlaces(2)
.toNumber();
let options = getOptions({
const options = getOptions({
prodItems: mapToProductItems({
prices: newProduct.prices,
entitlements: newProduct.entitlements,
@@ -256,10 +256,10 @@ export const getUpgradeProductPreview = async ({
items = items.filter((item) => item.amount !== 0);
if (branch == AttachBranch.UpdatePrepaidQuantity) {
items = items.filter((item) => item.usage_model == UsageModel.Prepaid);
if (branch === AttachBranch.UpdatePrepaidQuantity) {
items = items.filter((item) => item.usage_model === UsageModel.Prepaid);
dueNextCycle!.line_items = dueNextCycle!.line_items.filter(
(item) => item.usage_model == UsageModel.Prepaid,
(item) => item.usage_model === UsageModel.Prepaid,
);
items = filterNoProratePrepaidItems({
@@ -279,11 +279,20 @@ export const getUpgradeProductPreview = async ({
total: dueTodayAmt,
};
if (branch == AttachBranch.SameCustomEnts) {
if (branch === AttachBranch.SameCustomEnts) {
dueToday = undefined;
}
if (branch == AttachBranch.NewVersion && dueToday) {
if (branch === AttachBranch.NewVersion && dueToday) {
const curProduct = cusProductToProduct({ cusProduct: curCusProduct });
const newProduct = attachParamsToProduct({ attachParams });
if (isFreeProduct(curProduct.prices) && !isFreeProduct(newProduct.prices)) {
throw new Error(
`Version ${curProduct.version} is free, cannot upgrade to version ${newProduct.version} which has a paid price`,
);
}
dueToday.line_items = [];
dueToday.total = 0;
}

View File

@@ -1,16 +1,14 @@
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
AppEnv,
type AppEnv,
CouponDurationType,
CusExpand,
FullCustomer,
Organization,
type FullCustomer,
type Organization,
RewardType,
Subscription,
} from "@autumn/shared";
import Stripe from "stripe";
import type Stripe from "stripe";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
export const getCusRewards = async ({
org,
@@ -33,14 +31,14 @@ export const getCusRewards = async ({
return undefined;
}
let stripeCli = createStripeCli({
const stripeCli = createStripeCli({
org,
env,
});
const [stripeCus, stripeSubs] = await Promise.all([
stripeCli.customers.retrieve(
fullCus.processor?.id!,
fullCus.processor?.id,
) as Promise<Stripe.Customer>,
getStripeSubs({
stripeCli,
@@ -49,7 +47,7 @@ export const getCusRewards = async ({
}),
]);
let stripeDiscounts: Stripe.Discount[] = stripeSubs?.flatMap(
const stripeDiscounts: Stripe.Discount[] = stripeSubs?.flatMap(
(s) => s.discounts,
) as Stripe.Discount[];
@@ -57,7 +55,7 @@ export const getCusRewards = async ({
stripeDiscounts.push(stripeCus.discount);
}
let rewards = {
const rewards = {
discounts: stripeDiscounts.map((d) => {
let duration_type: CouponDurationType;
let duration_value = 0;

View File

@@ -0,0 +1,94 @@
import {
type AppEnv,
CusExpand,
type FullCustomer,
type Organization,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { getEarliestPeriodEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { lineItemInCusProduct } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { stripeDiscountToResponse } from "./stripeDiscountToResponse.js";
export const getCusUpcomingInvoice = async ({
db,
org,
env,
fullCus,
expand,
}: {
db: DrizzleCli;
org: Organization;
env: AppEnv;
fullCus: FullCustomer;
expand: CusExpand[];
}) => {
if (!expand.includes(CusExpand.UpcomingInvoice)) return undefined;
const subIds = fullCus.customer_products.flatMap(
(cp) => cp.subscription_ids || [],
);
if (subIds.length === 0) return null;
const stripeCli = createStripeCli({ org, env });
const subs = await getStripeSubs({
stripeCli,
subIds,
});
const sub = subs.reduce((acc, sub) => {
const curSubPeriodEnd = getEarliestPeriodEnd({ sub });
const nextSubPeriodEnd = getEarliestPeriodEnd({ sub });
return nextSubPeriodEnd < curSubPeriodEnd ? sub : acc;
}, subs[0]);
const upcomingInvoice = await stripeCli.invoices.createPreview({
customer: fullCus.processor?.id,
subscription: sub.id,
expand: ["discounts.coupon"],
});
const lines = [];
for (const line of upcomingInvoice.lines.data) {
const cusProd = fullCus.customer_products.find((cp) =>
lineItemInCusProduct({ cusProduct: cp, lineItem: line }),
);
lines.push({
product_id: cusProd?.product.id || null,
description: line.description,
amount: new Decimal(line.amount).div(100).toDecimalPlaces(2).toNumber(),
});
}
const stripeDiscounts = upcomingInvoice.discounts.filter(
(d): d is Stripe.Discount =>
typeof d === "object" && d !== null && "coupon" in d,
) as Stripe.Discount[];
// Get reward in IDs
const discounts = stripeDiscounts.map((d) =>
stripeDiscountToResponse({
discount: d,
totalDiscountAmounts: upcomingInvoice.total_discount_amounts || undefined,
}),
);
// console.log("lines: ", lines);
// console.log("discounts: ", discounts);
const res = {
lines,
discounts,
subtotal: upcomingInvoice.subtotal / 100,
total: upcomingInvoice.total / 100,
currency: upcomingInvoice.currency,
};
return res;
};

View File

@@ -0,0 +1,58 @@
import { CouponDurationType, RewardType } from "@autumn/shared";
import type Stripe from "stripe";
const parseStripeCouponDuration = (coupon: Stripe.Coupon) => {
let duration_type: CouponDurationType = CouponDurationType.OneOff;
let duration_value: number = 0;
if (coupon.duration === "forever") {
duration_type = CouponDurationType.Forever;
} else if (coupon.duration === "once") {
duration_type = CouponDurationType.OneOff;
} else if (coupon.duration === "repeating") {
duration_type = CouponDurationType.Months;
duration_value = coupon.duration_in_months || 0;
} else {
duration_type = CouponDurationType.OneOff;
}
return {
duration_type,
duration_value,
};
};
export const stripeDiscountToResponse = ({
discount,
totalDiscountAmounts,
}: {
discount: Stripe.Discount;
totalDiscountAmounts?: Stripe.Invoice.TotalDiscountAmount[];
}) => {
const d = discount;
const { duration_type, duration_value } = parseStripeCouponDuration(d.coupon);
const totalDiscountAmount = totalDiscountAmounts?.find(
(t) => t.discount === d.id,
);
return {
id: d.coupon?.id,
name: d.coupon?.name ?? "",
type: d.coupon?.amount_off
? RewardType.FixedDiscount
: RewardType.PercentageDiscount,
discount_value: d.coupon?.amount_off || d.coupon?.percent_off || 0,
currency: d.coupon?.currency ?? null,
start: d.start ?? null,
end: d.end ?? null,
// subscription_id: d.subscription ?? null,
duration_type,
duration_value,
total_discount_amount: totalDiscountAmount?.amount
? totalDiscountAmount.amount / 100
: null,
};
};

View File

@@ -1,33 +1,33 @@
import { BREAK_API_VERSION } from "@/utils/constants.js";
import {
AppEnv,
CusProductStatus,
FullCusProduct,
APIVersion,
type AppEnv,
CusEntResponseSchema,
CusExpand,
CusProductStatus,
CusResponseSchema,
CustomerResponseSchema,
CusEntResponseSchema,
FeatureType,
Feature,
Organization,
FullCustomer,
CusExpand,
RewardResponse,
cusProductsToCusEnts,
cusProductsToCusPrices,
EntityResponseSchema,
type Feature,
FeatureType,
type FullCusProduct,
type FullCustomer,
type Organization,
type RewardResponse,
} from "@autumn/shared";
import { getCusInvoices } from "./cusUtils.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { cusProductsToCusEnts, cusProductsToCusPrices } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { invoicesToResponse } from "@/internal/invoices/invoiceUtils.js";
import { getCusBalances } from "./cusFeatureResponseUtils/getCusBalances.js";
import { BREAK_API_VERSION } from "@/utils/constants.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { featuresToObject } from "./cusFeatureResponseUtils/balancesToFeatureResponse.js";
import { getCusBalances } from "./cusFeatureResponseUtils/getCusBalances.js";
import { processFullCusProducts } from "./cusProductResponseUtils/processFullCusProducts.js";
import { getCusPaymentMethodRes } from "./cusResponseUtils/getCusPaymentMethodRes.js";
import { getCusReferrals } from "./cusResponseUtils/getCusReferrals.js";
import { getCusRewards } from "./cusResponseUtils/getCusRewards.js";
import { getCusPaymentMethodRes } from "./cusResponseUtils/getCusPaymentMethodRes.js";
import { getCusUpcomingInvoice } from "./cusResponseUtils/getCusUpcomingInvoice.js";
import { getCusInvoices } from "./cusUtils.js";
export const getCustomerDetails = async ({
db,
@@ -52,18 +52,18 @@ export const getCustomerDetails = async ({
expand: CusExpand[];
reqApiVersion?: number;
}) => {
let apiVersion = orgToVersion({
const apiVersion = orgToVersion({
org,
reqApiVersion,
});
let withRewards = expand.includes(CusExpand.Rewards);
const withRewards = expand.includes(CusExpand.Rewards);
let inStatuses = org.config.include_past_due
const inStatuses = org.config.include_past_due
? [CusProductStatus.Active, CusProductStatus.PastDue]
: [CusProductStatus.Active];
let cusEnts = cusProductsToCusEnts({ cusProducts, inStatuses }) as any;
const cusEnts = cusProductsToCusEnts({ cusProducts, inStatuses }) as any;
const balances = await getCusBalances({
cusEntsWithCusProduct: cusEnts,
@@ -72,7 +72,7 @@ export const getCustomerDetails = async ({
apiVersion,
});
let subIds = cusProducts.flatMap(
const subIds = cusProducts.flatMap(
(cp: FullCusProduct) => cp.subscription_ids || [],
);
@@ -87,8 +87,8 @@ export const getCustomerDetails = async ({
if (apiVersion >= APIVersion.v1_1) {
let entList: any = balances.map((b) => {
let isBoolean =
features.find((f: Feature) => f.id == b.feature_id)?.type ==
const isBoolean =
features.find((f: Feature) => f.id === b.feature_id)?.type ===
FeatureType.Boolean;
if (b.unlimited || isBoolean) {
return b;
@@ -101,7 +101,7 @@ export const getCustomerDetails = async ({
});
});
let products: any = [...main, ...addOns];
const products: any = [...main, ...addOns];
if (apiVersion >= APIVersion.v1_2) {
entList = featuresToObject({
@@ -110,9 +110,9 @@ export const getCustomerDetails = async ({
});
}
let withInvoices = expand.includes(CusExpand.Invoices);
const withInvoices = expand.includes(CusExpand.Invoices);
let rewards: RewardResponse | undefined = await getCusRewards({
const rewards: RewardResponse | undefined = await getCusRewards({
org,
env,
fullCus: customer,
@@ -120,26 +120,35 @@ export const getCustomerDetails = async ({
expand,
});
let referrals = await getCusReferrals({
const upcomingInvoice = await getCusUpcomingInvoice({
db,
fullCus: customer,
expand,
});
let paymentMethod = await getCusPaymentMethodRes({
org,
env,
fullCus: customer,
expand,
});
let cusResponse = {
const referrals = await getCusReferrals({
db,
fullCus: customer,
expand,
});
const paymentMethod = await getCusPaymentMethodRes({
org,
env,
fullCus: customer,
expand,
});
const cusResponse = {
...CusResponseSchema.parse({
...customer,
stripe_id: customer.processor?.id,
features: entList,
products,
// invoices: withInvoices ? invoices : undefined,
invoices: withInvoices
? invoicesToResponse({
invoices: customer.invoices || [],
@@ -165,6 +174,7 @@ export const getCustomerDetails = async ({
: undefined,
referrals,
payment_method: paymentMethod,
upcoming_invoice: upcomingInvoice,
}),
};
@@ -177,7 +187,7 @@ export const getCustomerDetails = async ({
return cusResponse;
}
} else {
let withItems = org.config.api_version >= BREAK_API_VERSION;
const withItems = org.config.api_version >= BREAK_API_VERSION;
const processedInvoices = await getCusInvoices({
db,

View File

@@ -1,10 +1,10 @@
import { routeHandler } from "@/utils/routerUtils.js";
import { APIVersion, CusExpand, ErrCode } from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { orgToVersion } from "@/utils/versionUtils.js";
import { getCusWithCache } from "../cusCache/getCusWithCache.js";
import { parseCusExpand } from "../cusUtils/cusUtils.js";
import { getCustomerDetails } from "../cusUtils/getCustomerDetails.js";
export const handleGetCustomer = async (req: any, res: any) =>
routeHandler({
@@ -12,18 +12,18 @@ export const handleGetCustomer = async (req: any, res: any) =>
res,
action: "get customer",
handler: async () => {
let customerId = req.params.customer_id;
let { env, db, logtail: logger, org, features } = req;
let { expand } = req.query;
const customerId = req.params.customer_id;
const { env, db, logtail: logger, org, features } = req;
const { expand } = req.query;
let expandArray = parseCusExpand(expand);
const expandArray = parseCusExpand(expand);
let apiVersion = orgToVersion({
const apiVersion = orgToVersion({
org,
reqApiVersion: req.apiVersion,
});
let getInvoices = apiVersion < APIVersion.v1_1;
const getInvoices = apiVersion < APIVersion.v1_1;
if (getInvoices) expandArray.push(CusExpand.Invoices);
logger.info(`getting customer ${customerId} for org ${org.slug}`);
@@ -51,7 +51,7 @@ export const handleGetCustomer = async (req: any, res: any) =>
return;
}
let cusData = await getCustomerDetails({
const cusData = await getCustomerDetails({
db,
customer,
org,

View File

@@ -1,54 +1,51 @@
import {
EntitlementWithFeature,
FullProduct,
Organization,
Price,
Feature,
BillingInterval,
FreeTrial,
PreviewLineItem,
BillingType,
type EntitlementWithFeature,
type Feature,
type FreeTrial,
type FullProduct,
getFeatureInvoiceDescription,
UsagePriceConfig,
UsageModel,
AttachConfig,
AttachBranch,
ProrationBehavior,
IntervalConfig,
} from "@autumn/shared";
import { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import {
formatPrice,
getBillingType,
getPriceForOverage,
getPriceOptions,
} from "../../products/prices/priceUtils.js";
import { getPriceEntitlement } from "../../products/prices/priceUtils.js";
import {
isFixedPrice,
isOneOffPrice,
isPrepaidPrice,
type IntervalConfig,
isUsagePrice,
} from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js";
import { calculateProrationAmount } from "../prorationUtils.js";
import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js";
import { toProductItem } from "@autumn/shared";
import { formatAmount } from "@/utils/formatUtils.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js";
type Organization,
type PreviewLineItem,
type Price,
toProductItem,
UsageModel,
type UsagePriceConfig,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import {
priceToFeature,
priceToUsageModel,
} from "@/internal/products/prices/priceUtils/convertPrice.js";
import { getContUseInvoiceItems } from "@/internal/customers/attach/attachUtils/getContUseItems/getContUseInvoiceItems.js";
import Stripe from "stripe";
import { attachParamsToCurCusProduct } from "@/internal/customers/attach/attachUtils/convertAttachParams.js";
import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js";
import { priceToInvoiceAmount } from "@/internal/products/prices/priceUtils/priceToInvoiceAmount.js";
import { Decimal } from "decimal.js";
import { getAlignedUnix } from "@/internal/products/prices/billingIntervalUtils2.js";
import { sortPricesByType } from "@/internal/products/prices/priceUtils/sortPriceUtils.js";
import { formatAmount } from "@/utils/formatUtils.js";
import { formatUnixToDate, notNullish } from "@/utils/genUtils.js";
import type { AttachParams } from "../../customers/cusProducts/AttachParams.js";
import { getPricecnPrice } from "../../products/pricecn/pricecnUtils.js";
import { subtractIntervalForProration } from "../../products/prices/billingIntervalUtils.js";
import {
isFixedPrice,
isOneOffPrice,
isPrepaidPrice,
} from "../../products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import {
formatPrice,
getBillingType,
getPriceEntitlement,
getPriceForOverage,
getPriceOptions,
} from "../../products/prices/priceUtils.js";
import { newPriceToInvoiceDescription } from "../invoiceFormatUtils.js";
import { calculateProrationAmount } from "../prorationUtils.js";
export const getDefaultPriceStr = ({
org,
@@ -94,7 +91,7 @@ export const getProration = ({
intervalCount = intervalCount ?? 1;
now = now || Date.now();
if (interval == BillingInterval.OneOff) return undefined;
if (interval === BillingInterval.OneOff) return undefined;
let end = proration?.end;
if (!end && anchor) {
@@ -220,7 +217,7 @@ export const getItemsForNewProduct = async ({
continue;
}
if (billingType == BillingType.UsageInArrear) {
if (billingType === BillingType.UsageInArrear) {
items.push({
price: getDefaultPriceStr({ org, price, ent: ent!, features }),
description: newPriceToInvoiceDescription({
@@ -236,8 +233,8 @@ export const getItemsForNewProduct = async ({
}
if (withPrepaid && isPrepaidPrice({ price })) {
let options = getPriceOptions(price, attachParams.optionsList);
let quantity = notNullish(options?.quantity) ? options?.quantity! : 1;
const options = getPriceOptions(price, attachParams.optionsList);
const quantity = notNullish(options?.quantity) ? options?.quantity! : 1;
const quantityWithBillingUnits = new Decimal(quantity).mul(
(price.config as UsagePriceConfig).billing_units || 1,
@@ -245,14 +242,14 @@ export const getItemsForNewProduct = async ({
// console.log("price", price);
// console.log("Quantity", quantity);
let amount = priceToInvoiceAmount({
const amount = priceToInvoiceAmount({
price,
quantity: quantityWithBillingUnits.toNumber(),
proration: finalProration,
now,
});
// console.log("Amount", amount);
let feature = priceToFeature({
const feature = priceToFeature({
price,
features,
})!;
@@ -281,7 +278,7 @@ export const getItemsForNewProduct = async ({
attachParams,
});
let { newItems } = await getContUseInvoiceItems({
const { newItems } = await getContUseInvoiceItems({
cusProduct,
sub,
attachParams,

View File

@@ -1,163 +1,158 @@
import { ProductService } from "@/internal/products/ProductService.js";
import RecaseError from "@/utils/errorUtils.js";
import {
BillingType,
CusProductStatus,
ErrCode,
UsagePriceConfig,
} from "@autumn/shared";
import { routeHandler } from "@/utils/routerUtils.js";
import express, { Router } from "express";
import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js";
import { BillingType, ErrCode, type UsagePriceConfig } from "@autumn/shared";
import express, { type Router } from "express";
import { MigrationService } from "@/internal/migrations/MigrationService.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { constructMigrationJob } from "@/internal/migrations/migrationUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {
getBillingType,
pricesOnlyOneOff,
} from "@/internal/products/prices/priceUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { ExtendedRequest, ExtendedResponse } from "@/utils/models/Request.js";
import { JobName } from "@/queue/JobName.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import type {
ExtendedRequest,
ExtendedResponse,
} from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { findPrepaidPrice } from "../products/prices/priceUtils/findPriceUtils.js";
export const migrationRouter: Router = express.Router();
export const handleMigrate = async (
req: ExtendedRequest,
res?: ExtendedResponse,
) => {
const { orgId, env, db, features } = req;
const { from_product_id, from_version, to_product_id, to_version } = req.body;
const fromProduct = await ProductService.getFull({
db,
env,
orgId,
idOrInternalId: from_product_id,
version: from_version,
});
const toProduct = await ProductService.getFull({
db,
env,
orgId,
idOrInternalId: to_product_id,
version: to_version,
});
if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) {
throw new RecaseError({
message: `Cannot migrate customers from free product to paid product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Check if from product is one off, or to product is one off
if (
pricesOnlyOneOff(fromProduct.prices) ||
pricesOnlyOneOff(toProduct.prices)
) {
const fromIsOneOff = pricesOnlyOneOff(fromProduct.prices);
const msg = fromIsOneOff
? `${fromProduct.name} is a one off product, cannot migrate customers on it`
: `${toProduct.name} is a one off product, cannot migrate customers to this product`;
throw new RecaseError({
message: msg,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (fromProduct.is_add_on || toProduct.is_add_on) {
throw new RecaseError({
message: `Cannot migrate customers for add on products`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
for (const price of toProduct.prices) {
const billingType = getBillingType(price.config);
if (billingType !== BillingType.UsageInAdvance) continue;
const config = price.config as UsagePriceConfig;
const internalFeatureId = config.internal_feature_id;
const feature = features.find((f) => f.internal_id === internalFeatureId);
const prepaidPrice = findPrepaidPrice({
prices: fromProduct.prices,
internalFeatureId,
});
if (!prepaidPrice) {
throw new RecaseError({
message: `New product has prepaid price for feature ${feature?.name}, but old product does not, can't perform migration`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
if (!isFreeProduct(fromProduct.prices) && isFreeProduct(toProduct.prices)) {
throw new RecaseError({
message: `Cannot migrate customers from paid product to free product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (isFreeProduct(fromProduct.prices) && !isFreeProduct(toProduct.prices)) {
throw new RecaseError({
message: `Cannot migrate customers from free product to paid product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// 1. Create migration JOB
const migrationJob = constructMigrationJob({
fromProduct,
toProduct,
});
await MigrationService.createJob({
db,
data: migrationJob,
});
if (!fromProduct || !toProduct) {
throw new RecaseError({
message: `Product ${from_product_id} version ${from_version} or ${to_product_id} version ${to_version} not found`,
code: ErrCode.ProductNotFound,
statusCode: 404,
});
}
await addTaskToQueue({
jobName: JobName.Migration,
payload: {
migrationJobId: migrationJob.id,
},
});
if (res) {
res.status(200).json(migrationJob);
}
};
migrationRouter.post("", async (req: any, res: any) => {
return routeHandler({
req,
res,
action: "migrate",
handler: async (req: ExtendedRequest, res: ExtendedResponse) => {
const { orgId, env, db, features } = req;
const { from_product_id, from_version, to_product_id, to_version } =
req.body;
let fromProduct = await ProductService.getFull({
db,
env,
orgId,
idOrInternalId: from_product_id,
version: from_version,
});
let toProduct = await ProductService.getFull({
db,
env,
orgId,
idOrInternalId: to_product_id,
version: to_version,
});
if (
isFreeProduct(fromProduct.prices) &&
!isFreeProduct(toProduct.prices)
) {
throw new RecaseError({
message: `Cannot migrate customers from free product to paid product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// Check if from product is one off, or to product is one off
if (
pricesOnlyOneOff(fromProduct.prices) ||
pricesOnlyOneOff(toProduct.prices)
) {
let fromIsOneOff = pricesOnlyOneOff(fromProduct.prices);
let msg = fromIsOneOff
? `${fromProduct.name} is a one off product, cannot migrate customers on it`
: `${toProduct.name} is a one off product, cannot migrate customers to this product`;
throw new RecaseError({
message: msg,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (fromProduct.is_add_on || toProduct.is_add_on) {
throw new RecaseError({
message: `Cannot migrate customers for add on products`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
for (const price of toProduct.prices) {
let billingType = getBillingType(price.config);
if (billingType != BillingType.UsageInAdvance) continue;
let config = price.config as UsagePriceConfig;
let internalFeatureId = config.internal_feature_id;
let feature = features.find((f) => f.internal_id == internalFeatureId)!;
for (const price of fromProduct.prices) {
let prepaidPrice = findPrepaidPrice({
prices: fromProduct.prices,
internalFeatureId,
});
if (!prepaidPrice) {
throw new RecaseError({
message: `New product has prepaid price for feature ${feature.name}, but old product does not, can't perform migration`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
}
if (
!isFreeProduct(fromProduct.prices) &&
isFreeProduct(toProduct.prices)
) {
throw new RecaseError({
message: `Cannot migrate customers from paid product to free product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
if (
isFreeProduct(fromProduct.prices) &&
!isFreeProduct(toProduct.prices)
) {
throw new RecaseError({
message: `Cannot migrate customers from free product to paid product`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
// 1. Create migration JOB
let migrationJob = constructMigrationJob({
fromProduct,
toProduct,
});
await MigrationService.createJob({
db,
data: migrationJob,
});
if (!fromProduct || !toProduct) {
throw new RecaseError({
message: `Product ${from_product_id} version ${from_version} or ${to_product_id} version ${to_version} not found`,
code: ErrCode.ProductNotFound,
statusCode: 404,
});
}
await addTaskToQueue({
jobName: JobName.Migration,
payload: {
migrationJobId: migrationJob.id,
},
});
res.status(200).json(migrationJob);
await handleMigrate(req, res);
},
});
});

View File

@@ -1,28 +1,26 @@
import {
type AppEnv,
type DiscountConfig,
type FixedPriceConfig,
type FullProduct,
type Price,
type UsagePriceConfig,
DiscountConfig,
PriceType,
RewardType,
getBillingType,
isFixedPrice,
isUsagePrice,
type Price,
PriceType,
RewardType,
type UsagePriceConfig,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { logger as loggerType } from "@/external/logtail/logtailUtils.js";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import type { JobName } from "@/queue/JobName.js";
import type { Payloads } from "@/queue/queueUtils.js";
import { RewardService } from "../rewards/RewardService.js";
import { tiersAreSame } from "../products/prices/priceInitUtils.js";
import { createStripeCoupon } from "@/external/stripe/stripeCouponUtils/stripeCouponUtils.js";
import { PriceService } from "../products/prices/PriceService.js";
import { OrgService } from "../orgs/OrgService.js";
import { formatPrice } from "../products/prices/priceUtils.js";
import { ProductService } from "../products/ProductService.js";
import { PriceService } from "../products/prices/PriceService.js";
import { tiersAreSame } from "../products/prices/priceInitUtils.js";
import { RewardService } from "../rewards/RewardService.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: any[], newTiers: any[]): boolean => {
@@ -65,8 +63,8 @@ const findMatchingUsagePrice = (
return false;
// Match by billing behavior
let newBillingType = getBillingType(newConfig);
let oldBillingType = getBillingType(oldConfig);
const newBillingType = getBillingType(newConfig);
const oldBillingType = getBillingType(oldConfig);
if (newBillingType !== oldBillingType) return false;
// Optionally match by tier structure
@@ -93,7 +91,7 @@ const findBestMatch = (oldPrice: Price, newPrices: Price[]): Price | null => {
getBillingType(newPrice.config) === getBillingType(oldPrice.config) &&
newPrice.config.interval === oldPrice.config.interval &&
newPrice.config.interval_count === oldPrice.config.interval_count &&
(oldConfig.type == PriceType.Usage
(oldConfig.type === PriceType.Usage
? oldConfig.internal_feature_id === newConfig.internal_feature_id
: true)
);

View File

@@ -2,73 +2,50 @@ import {
APIVersion,
BillingInterval,
BillingType,
FullCusProduct,
type FullCusProduct,
OnDecrease,
OnIncrease,
Price,
UsagePriceConfig,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import { formatPrice, getBillingType } from "../../priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import Stripe from "stripe";
import { Decimal } from "decimal.js";
import type Stripe from "stripe";
import { notNullish } from "@/utils/genUtils.js";
import { getBillingType } from "../../priceUtils.js";
export const isOneOffPrice = ({ price }: { price: Price }) => {
return price.config.interval == BillingInterval.OneOff;
};
export const isUsagePrice = ({
price,
featureId,
}: {
price: Price;
featureId?: string;
}) => {
let billingType = getBillingType(price.config);
let isUsage =
billingType == BillingType.UsageInArrear ||
billingType == BillingType.InArrearProrated ||
billingType == BillingType.UsageInAdvance;
if (featureId) {
return (
isUsage && (price.config as UsagePriceConfig).feature_id == featureId
);
}
return isUsage;
return price.config.interval === BillingInterval.OneOff;
};
export const isArrearPrice = ({ price }: { price?: Price }) => {
if (!price) return false;
let billingType = getBillingType(price.config);
return billingType == BillingType.UsageInArrear;
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInArrear;
};
export const isContUsePrice = ({ price }: { price?: Price }) => {
if (!price) return false;
let billingType = getBillingType(price.config);
return billingType == BillingType.InArrearProrated;
const billingType = getBillingType(price.config);
return billingType === BillingType.InArrearProrated;
};
export const isPrepaidPrice = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
return billingType == BillingType.UsageInAdvance;
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInAdvance;
};
export const isPayPerUse = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
return (
billingType == BillingType.UsageInArrear ||
billingType == BillingType.InArrearProrated
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
);
};
export const isFixedPrice = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
return (
billingType == BillingType.FixedCycle || billingType == BillingType.OneOff
billingType === BillingType.FixedCycle || billingType === BillingType.OneOff
);
};
@@ -80,8 +57,8 @@ export const hasPrepaidPrice = ({
excludeOneOff?: boolean;
}) => {
return prices.some((price) => {
let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance;
let isOneOff = price.config.interval == BillingInterval.OneOff;
const isUsage = getBillingType(price.config) === BillingType.UsageInAdvance;
const isOneOff = price.config.interval === BillingInterval.OneOff;
return isUsage && (excludeOneOff ? !isOneOff : true);
});
@@ -97,8 +74,8 @@ export const isV4Usage = ({
const billingType = getBillingType(price.config);
return (
billingType == BillingType.UsageInArrear &&
(cusProduct.api_version == APIVersion.v1_4 ||
billingType === BillingType.UsageInArrear &&
(cusProduct.api_version === APIVersion.v1_4 ||
notNullish(cusProduct.internal_entity_id))
);
};
@@ -143,10 +120,10 @@ export const roundUsage = ({
price: Price;
pos?: boolean;
}) => {
let config = price.config as UsagePriceConfig;
let billingUnits = config.billing_units || 1;
const config = price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let rounded = new Decimal(usage)
const rounded = new Decimal(usage)
.div(billingUnits)
.ceil()
.mul(billingUnits)

View File

@@ -1,4 +1,4 @@
import { FullProduct, Product, ProductV2 } from "@autumn/shared";
import type { FullProduct } from "@autumn/shared";
import { isProductUpgrade } from "../productUtils.js";
export const sortProductsByPrice = ({
@@ -7,7 +7,7 @@ export const sortProductsByPrice = ({
products: FullProduct[];
}) => {
products.sort((a, b) => {
let isUpgradeA = isProductUpgrade({
const isUpgradeA = isProductUpgrade({
prices1: a.prices,
prices2: b.prices,
usageAlwaysUpgrade: false,

View File

@@ -2,8 +2,8 @@ import {
type AppEnv,
ErrCode,
type Reward,
type RewardType,
rewards,
RewardType,
} from "@autumn/shared";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle.js";
@@ -39,6 +39,26 @@ export class RewardService {
return result as Reward;
}
static async getInIds({
db,
ids,
orgId,
env,
}: {
db: DrizzleCli;
ids: string[];
orgId: string;
env: AppEnv;
}) {
return (await db.query.rewards.findMany({
where: and(
inArray(rewards.id, ids),
eq(rewards.org_id, orgId),
eq(rewards.env, env),
),
})) as Reward[];
}
static async getByIdOrCode({
db,
codes,

View File

@@ -1,26 +1,23 @@
import Stripe from "stripe";
import {
BillingInterval,
type CusProductStatus,
type EntitlementWithFeature,
type FullCustomer,
type FullProduct,
isUsagePrice,
type Price,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import { stripeToAutumnSubStatus } from "@/external/stripe/stripeSubUtils.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { constructSub } from "@/internal/subscriptions/subUtils.js";
import {
FullCustomer,
FullProduct,
Price,
EntitlementWithFeature,
CusProductStatus,
UsagePriceConfig,
BillingInterval,
} from "@autumn/shared";
import { notNullish } from "../genUtils.js";
import { ExtendedRequest } from "../models/Request.js";
import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
import { subToAutumnInterval } from "@/external/stripe/utils.js";
import { prices as priceTable } from "@autumn/shared";
import { PriceService } from "@/internal/products/prices/PriceService.js";
import { subToPeriodStartEnd } from "@/external/stripe/stripeSubUtils/convertSubUtils.js";
import type { ExtendedRequest } from "../models/Request.js";
export const addProductFromSubs = async ({
req,
@@ -48,17 +45,17 @@ export const addProductFromSubs = async ({
const cusProducts = autumnCus.customer_products;
const entity = autumnCus.entity;
let mainCusProduct = cusProducts.find(
const mainCusProduct = cusProducts.find(
(cp) =>
!cp.product.is_add_on &&
cp.product_id == autumnProduct.id &&
cp.product_id === autumnProduct.id &&
(notNullish(entity)
? cp.internal_entity_id == entity!.internal_id
? cp.internal_entity_id === entity!.internal_id
: true),
);
if (mainCusProduct && !force) {
let prices = mainCusProduct.customer_prices.map((cp) => cp.price);
const prices = mainCusProduct.customer_prices.map((cp) => cp.price);
// let isFree = isFreeProduct(prices);
if (mainCusProduct) {
@@ -74,12 +71,12 @@ export const addProductFromSubs = async ({
}
// Handle if trialing
let trialEndsAt = sub?.trial_end ? sub.trial_end * 1000 : null;
const trialEndsAt = sub?.trial_end ? sub.trial_end * 1000 : null;
// throw new Error("test");
// 1. Insert custom prices...
let customPrices = prices?.filter((p) => p.is_custom);
const customPrices = prices?.filter((p) => p.is_custom);
if (customPrices && customPrices.length > 0) {
await PriceService.upsert({
db,
@@ -91,7 +88,7 @@ export const addProductFromSubs = async ({
sub,
});
let newCusProduct = await createFullCusProduct({
const newCusProduct = await createFullCusProduct({
db,
attachParams: {
replaceables: [],
@@ -132,16 +129,16 @@ export const addProductFromSubs = async ({
if (sub) {
// Create sub
let usageFeatures = autumnProduct.prices
const usageFeatures = autumnProduct.prices
.filter((p) => isUsagePrice({ price: p }))
.map((p) => (p.config as UsagePriceConfig).internal_feature_id);
let subFromDb = await SubService.getInStripeIds({
const subFromDb = await SubService.getInStripeIds({
db,
ids: [sub.id],
});
let subInterval = subToAutumnInterval(sub);
const subInterval = subToAutumnInterval(sub);
if (subFromDb.length === 0) {
await SubService.createSub({
@@ -149,7 +146,7 @@ export const addProductFromSubs = async ({
sub: constructSub({
stripeId: sub.id,
usageFeatures:
subInterval.interval == BillingInterval.Month ? usageFeatures : [],
subInterval.interval === BillingInterval.Month ? usageFeatures : [],
orgId: org.id,
env,
currentPeriodStart: start,

View File

@@ -1,8 +1,7 @@
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { type AppEnv, type CreateReward, isUsagePrice } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import type { AutumnInt } from "@/external/autumn/autumnCli.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { AppEnv, CreateReward, Product, ProductV2 } from "@autumn/shared";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js";
export const createProduct = async ({
db,
@@ -43,7 +42,7 @@ export const createProduct = async ({
await Promise.all(batchDelete);
} catch (error) {}
let clone = structuredClone(product);
const clone = structuredClone(product);
if (typeof clone.items === "object") {
clone.items = Object.values(clone.items);
}
@@ -106,14 +105,14 @@ export const createReward = async ({
productId: string;
onlyUsage?: boolean;
}) => {
let fullProduct = await ProductService.getFull({
const fullProduct = await ProductService.getFull({
db,
orgId,
env,
idOrInternalId: productId!,
});
let usagePrices = fullProduct?.prices.filter((price) =>
const usagePrices = fullProduct?.prices.filter((price) =>
isUsagePrice({ price }),
);

View File

@@ -1,8 +1,7 @@
import { z } from "zod";
import { AttachScenario } from "../checkModels/checkPreviewModels.js";
import { FeatureOptionsSchema } from "../cusProductModels/cusProductModels.js";
import { ProductItemResponseSchema } from "../productV2Models/productItemModels/prodItemResponseModels.js";
import { ProductResponseSchema } from "../productV2Models/productResponseModels.js";
import { FeatureOptionsSchema } from "../cusProductModels/cusProductModels.js";
export const CheckoutLineSchema = z.object({
description: z.string(),

View File

@@ -5,4 +5,5 @@ export enum CusExpand {
Entities = "entities",
Referrals = "referrals",
PaymentMethod = "payment_method",
UpcomingInvoice = "upcoming_invoice",
}

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
import { DiscountResponseSchema } from "../../rewardModels/rewardModels/rewardResponseModels.js";
export const UpcomingInvoiceResponseSchema = z.object({
lines: z.array(
z.object({
product_id: z.string().nullish(),
description: z.string(),
amount: z.number(),
}),
),
discounts: z.array(DiscountResponseSchema),
subtotal: z.number(),
total: z.number(),
currency: z.string(),
});
export type UpcomingInvoiceResponse = z.infer<
typeof UpcomingInvoiceResponseSchema
>;

View File

@@ -3,6 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js";
import { RewardResponseSchema } from "../rewardModels/rewardModels/rewardResponseModels.js";
import { CusProductResponseSchema } from "./cusResModels/cusProductResponse.js";
import { CusReferralResponseSchema } from "./cusResModels/cusReferralsResponse.js";
import { UpcomingInvoiceResponseSchema } from "./cusResModels/upcomingInvoiceResponse.js";
import { EntityResponseSchema } from "./entityModels/entityResModels.js";
import { InvoiceResponseSchema } from "./invoiceModels/invoiceResponseModels.js";
@@ -34,6 +35,7 @@ export const CusResponseSchema = z.object({
entities: z.array(EntityResponseSchema).optional(),
referrals: z.array(CusReferralResponseSchema).optional(),
payment_method: z.any().nullish(),
upcoming_invoice: UpcomingInvoiceResponseSchema.nullish(),
});
export type CusResponse = z.infer<typeof CusResponseSchema>;

View File

@@ -6,8 +6,14 @@ export const FixedPriceConfigSchema = z.object({
amount: z.number().min(0),
interval: z.nativeEnum(BillingInterval),
interval_count: z.number().nullish(),
// Usage price fields
billing_units: z.number().nullish(),
stripe_price_id: z.string().nullish(),
stripe_empty_price_id: z.string().nullish(),
stripe_product_id: z.null().or(z.undefined()),
feature_id: z.null().or(z.undefined()),
internal_feature_id: z.null().or(z.undefined()),
});
export type FixedPriceConfig = z.infer<typeof FixedPriceConfigSchema>;

View File

@@ -1,6 +1,5 @@
import { z } from "zod";
import { RewardType } from "./rewardEnums.js";
import { CouponDurationType } from "./rewardEnums.js";
import { CouponDurationType, RewardType } from "./rewardEnums.js";
export const DiscountResponseSchema = z.object({
id: z.string(), // either from Autumn or Stripe
@@ -17,7 +16,8 @@ export const DiscountResponseSchema = z.object({
start: z.number().nullish(),
end: z.number().nullish(),
subscription_id: z.string().nullable(),
subscription_id: z.string().nullish(),
total_discount_amount: z.number().nullish(),
});
export const RewardResponseSchema = z.object({

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import { Reward } from "../rewardModels/rewardModels.js";
import { RewardReceivedBy } from "./rewardProgramEnums.js";
import { RewardTriggerEvent } from "./rewardProgramEnums.js";
import type { Reward } from "../rewardModels/rewardModels.js";
import { RewardReceivedBy, RewardTriggerEvent } from "./rewardProgramEnums.js";
export const RewardProgram = z.object({
internal_id: z.string(),
@@ -33,6 +32,15 @@ export const CreateRewardProgram = z.object({
received_by: z.nativeEnum(RewardReceivedBy),
});
export const UpdateRewardProgram = z.object({
when: z.nativeEnum(RewardTriggerEvent),
product_ids: z.array(z.string()).optional(),
exclude_trial: z.boolean().optional(),
internal_reward_id: z.string(),
max_redemptions: z.number().optional(),
received_by: z.nativeEnum(RewardReceivedBy),
});
export type RewardProgram = z.infer<typeof RewardProgram>;
export type CreateRewardProgram = z.infer<typeof CreateRewardProgram>;

View File

@@ -1,44 +1,44 @@
import Stripe from "stripe";
import { Decimal } from "decimal.js";
import { notNullish } from "../utils.js";
import { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
import type Stripe from "stripe";
import { APIVersion } from "../../enums/APIVersion.js";
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import type { FixedPriceConfig } from "../../models/productModels/priceModels/priceConfig/fixedPriceConfig.js";
import {
BillWhen,
UsagePriceConfig,
type UsagePriceConfig,
} from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import {
BillingInterval,
BillingType,
PriceType,
} from "../../models/productModels/priceModels/priceEnums.js";
import { Price } from "../../models/productModels/priceModels/priceModels.js";
import { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
import type { Price } from "../../models/productModels/priceModels/priceModels.js";
import {
OnDecrease,
OnIncrease,
} from "../../models/productV2Models/productItemModels/productItemEnums.js";
import { APIVersion } from "../../enums/APIVersion.js";
import { notNullish } from "../utils.js";
export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => {
// 1. Fixed cycle / one off
if (
config.type == PriceType.Fixed &&
config.interval == BillingInterval.OneOff
config.type === PriceType.Fixed &&
config.interval === BillingInterval.OneOff
) {
return BillingType.OneOff;
} else if (config.type == PriceType.Fixed) {
} else if (config.type === PriceType.Fixed) {
return BillingType.FixedCycle;
}
// 2. Prepaid
let usageConfig = config as UsagePriceConfig;
const usageConfig = config as UsagePriceConfig;
if (
usageConfig.bill_when == BillWhen.InAdvance ||
usageConfig.bill_when == BillWhen.StartOfPeriod
usageConfig.bill_when === BillWhen.InAdvance ||
usageConfig.bill_when === BillWhen.StartOfPeriod
) {
return BillingType.UsageInAdvance;
} else if (usageConfig.bill_when == BillWhen.EndOfPeriod) {
} else if (usageConfig.bill_when === BillWhen.EndOfPeriod) {
if (usageConfig.should_prorate) {
return BillingType.InArrearProrated;
}
@@ -49,7 +49,7 @@ export const getBillingType = (config: FixedPriceConfig | UsagePriceConfig) => {
};
export const isOneOffPrice = ({ price }: { price: Price }) => {
return price.config.interval == BillingInterval.OneOff;
return price.config.interval === BillingInterval.OneOff;
};
export const isUsagePrice = ({
@@ -59,40 +59,38 @@ export const isUsagePrice = ({
price: Price;
featureId?: string;
}) => {
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
let isUsage =
billingType == BillingType.UsageInArrear ||
billingType == BillingType.InArrearProrated ||
billingType == BillingType.UsageInAdvance;
const isUsage =
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated ||
billingType === BillingType.UsageInAdvance;
if (featureId) {
return (
isUsage && (price.config as UsagePriceConfig).feature_id == featureId
);
return isUsage && price.config.feature_id === featureId;
}
return isUsage;
};
export const isPrepaidPrice = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
return billingType == BillingType.UsageInAdvance;
const billingType = getBillingType(price.config);
return billingType === BillingType.UsageInAdvance;
};
export const isPayPerUse = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
return (
billingType == BillingType.UsageInArrear ||
billingType == BillingType.InArrearProrated
billingType === BillingType.UsageInArrear ||
billingType === BillingType.InArrearProrated
);
};
export const isFixedPrice = ({ price }: { price: Price }) => {
let billingType = getBillingType(price.config);
const billingType = getBillingType(price.config);
return (
billingType == BillingType.FixedCycle || billingType == BillingType.OneOff
billingType === BillingType.FixedCycle || billingType === BillingType.OneOff
);
};
@@ -104,8 +102,8 @@ export const hasPrepaidPrice = ({
excludeOneOff?: boolean;
}) => {
return prices.some((price) => {
let isUsage = getBillingType(price.config) == BillingType.UsageInAdvance;
let isOneOff = price.config.interval == BillingInterval.OneOff;
const isUsage = getBillingType(price.config) === BillingType.UsageInAdvance;
const isOneOff = price.config.interval === BillingInterval.OneOff;
return isUsage && (excludeOneOff ? !isOneOff : true);
});
};
@@ -120,8 +118,8 @@ export const isV4Usage = ({
const billingType = getBillingType(price.config);
return (
billingType == BillingType.UsageInArrear &&
(cusProduct.api_version == APIVersion.v1_4 ||
billingType === BillingType.UsageInArrear &&
(cusProduct.api_version === APIVersion.v1_4 ||
notNullish(cusProduct.internal_entity_id))
);
};
@@ -166,10 +164,10 @@ export const roundUsage = ({
price: Price;
pos?: boolean;
}) => {
let config = price.config as UsagePriceConfig;
let billingUnits = config.billing_units || 1;
const config = price.config as UsagePriceConfig;
const billingUnits = config.billing_units || 1;
let rounded = new Decimal(usage)
const rounded = new Decimal(usage)
.div(billingUnits)
.ceil()
.mul(billingUnits)

View File

@@ -4,9 +4,13 @@ import type {
Reward,
RewardType,
UsagePriceConfig,
UsageTier,
} from "../../index.js";
import type { UsageTier } from "../../models/productModels/priceModels/priceConfig/usagePriceConfig.js";
import { isFixedPrice, isUsagePrice } from "../productUtils/priceUtils.js";
import {
getBillingType,
isFixedPrice,
isUsagePrice,
} from "../productUtils/priceUtils.js";
// Helper function to check if tier structures match
const tiersMatch = (oldTiers: UsageTier[], newTiers: UsageTier[]): boolean => {
@@ -42,6 +46,8 @@ const findMatchingUsagePrice = (
return (
candidates.find((candidate) => {
if (!isUsagePrice({ price: candidate })) return false;
const newConfig = candidate.config as UsagePriceConfig;
// Match by feature
@@ -50,8 +56,9 @@ const findMatchingUsagePrice = (
return false;
// Match by billing behavior
if (newConfig.bill_when !== oldConfig.bill_when) return false;
if (newConfig.should_prorate !== oldConfig.should_prorate) return false;
const newBilingType = getBillingType(newConfig);
const oldBilingType = getBillingType(oldConfig);
if (newBilingType !== oldBilingType) return false;
// Optionally match by tier structure
if (!tiersMatch(oldConfig.usage_tiers, newConfig.usage_tiers))

View File

@@ -51,6 +51,7 @@ export const RewardProgramConfig = ({
<div className="w-6/12">
<FieldLabel>Program ID</FieldLabel>
<Input
disabled={isUpdate}
value={rewardProgram.id || ""}
onChange={(e) =>
setRewardProgram({ ...rewardProgram, id: e.target.value })

View File

@@ -1,12 +1,13 @@
// import { useProductsContext } from "../ProductsContext";
import { type RewardProgram, RewardTriggerEvent } from "@autumn/shared";
import { useState } from "react";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { RewardProgram, RewardTriggerEvent } from "@autumn/shared";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { AdminHover } from "@/components/general/AdminHover";
// import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
import { Item, Row } from "@/components/general/TableGrid";
import { AdminHover } from "@/components/general/AdminHover";
import { useRewardsQuery } from "@/hooks/queries/useRewardsQuery";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { RewardProgramRowToolbar } from "./RewardProgramRowToolbar";
import UpdateRewardProgram from "./UpdateRewardPrograms";
@@ -58,7 +59,11 @@ export const RewardProgramsTable = () => {
</AdminHover>
</Item>
<Item className="col-span-4">
<span className="truncate">{rewardProgram.when}</span>
<span className="truncate">
{rewardProgram.when === RewardTriggerEvent.CustomerCreation
? "Customer Redemption"
: keyToTitle(rewardProgram.when)}
</span>
</Item>
<Item className="col-span-4">
<div className="flex items-center gap-1">
@@ -70,9 +75,9 @@ export const RewardProgramsTable = () => {
</div>
</Item>
<Item className="col-span-3">
{rewardProgram.when == RewardTriggerEvent.CustomerCreation
{rewardProgram.when === RewardTriggerEvent.CustomerCreation
? "Sign Up"
: rewardProgram.when == RewardTriggerEvent.Checkout
: rewardProgram.when === RewardTriggerEvent.Checkout
? "Checkout"
: keyToTitle(rewardProgram.when)}
</Item>