feature: entities can now have products of their own

This commit is contained in:
John Yeo
2025-05-07 20:40:12 +01:00
parent 08be4737a4
commit a2ec951efb
64 changed files with 1411 additions and 1022 deletions

38
package-lock.json generated
View File

@@ -5644,6 +5644,15 @@
"node": ">=4"
}
},
"node_modules/async-listen": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/async-listen/-/async-listen-3.1.0.tgz",
"integrity": "sha512-TkOhqze98lP+6e7SPbrBpyhTpfvqqX8VYKGn4uckrgPan4WQIHnTaUD2zZzZS18eVVDj4rHPcIZa1PGgvo1DfA==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -14848,7 +14857,7 @@
"@types/express": "^5.0.0",
"@unkey/api": "^0.29.0",
"ai": "^4.3.10",
"autumn-js": "^0.0.13",
"autumn-js": "^0.0.27",
"body-parser": "^1.20.3",
"bullmq": "^5.31.1",
"chai": "^5.1.2",
@@ -15442,18 +15451,16 @@
"node": ">=8"
}
},
"server/node_modules/async-listen": {
"version": "3.0.1",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"server/node_modules/autumn-js": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/autumn-js/-/autumn-js-0.0.13.tgz",
"integrity": "sha512-R+rfwELDdIUL09ESwryxOiAdM81JCWxc/G0XM/mDMGBPyA+yEE76Qzei7fpJgo+IxBVUaYphxefTO9srb304Gg==",
"version": "0.0.27",
"resolved": "https://registry.npmjs.org/autumn-js/-/autumn-js-0.0.27.tgz",
"integrity": "sha512-K5DCyZd67oIj7zk5QBbNOvG+Kw9B37ktuvWD+VczkPjs/RH0mleFp66WQhGS4PBeWh0o5pwq3nCufmqh9Lj57w==",
"license": "MIT",
"dependencies": {
"async-listen": "^3.1.0",
"commander": "^13.1.0",
"ora": "^8.2.0"
},
"peerDependencies": {
"@clerk/nextjs": "^6.16.0",
"@supabase/ssr": "^0.6.1",
@@ -15473,6 +15480,15 @@
}
}
},
"server/node_modules/autumn-js/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"server/node_modules/body-parser": {
"version": "1.20.3",
"license": "MIT",

View File

@@ -45,7 +45,7 @@
"@types/express": "^5.0.0",
"@unkey/api": "^0.29.0",
"ai": "^4.3.10",
"autumn-js": "^0.0.13",
"autumn-js": "^0.0.27",
"body-parser": "^1.20.3",
"bullmq": "^5.31.1",
"chai": "^5.1.2",

View File

@@ -70,4 +70,7 @@ export const ErrCode = {
// Payment errors
CardDeclinedError: "card_declined_error",
// Entity
EntityNotFound: "entity_not_found",
};

View File

@@ -20,10 +20,6 @@ export const createStripeFixedPrice = async ({
org: Organization;
curStripePrice: Stripe.Price | null;
}) => {
if (curStripePrice) {
return;
}
const config = price.config as FixedPriceConfig;
let amount = new Decimal(config.amount).mul(100).toNumber();

View File

@@ -19,7 +19,10 @@ import { createStripeFixedPrice } from "./createStripeFixedPrice.js";
import { createStripePrepaid } from "./createStripePrepaid.js";
import { createStripeOneOffTieredProduct } from "./createStripeOneOffTiered.js";
import { createStripeInArrearPrice } from "./createStripeInArrear.js";
import { createStripeArrearProrated } from "./createStripeArrearProrated.js";
import {
createStripeArrearProrated,
createStripeMeteredPrice,
} from "./createStripeArrearProrated.js";
export const checkCurStripePrice = async ({
price,
@@ -100,8 +103,9 @@ export const createStripePriceIFNotExist = async ({
stripeCli,
});
price.config!.stripe_price_id = stripePrice?.id;
(price.config! as UsagePriceConfig).stripe_product_id = stripeProd?.id;
let config = price.config! as UsagePriceConfig;
config.stripe_price_id = stripePrice?.id;
config.stripe_product_id = stripeProd?.id;
let relatedEnt = getPriceEntitlement(price, entitlements);
let isOneOffAndTiered = priceIsOneOffAndTiered(price, relatedEnt);
@@ -111,15 +115,17 @@ export const createStripePriceIFNotExist = async ({
billingType == BillingType.FixedCycle ||
billingType == BillingType.OneOff
) {
logger.info("Creating stripe fixed price");
await createStripeFixedPrice({
sb,
stripeCli,
price,
product,
org,
curStripePrice: stripePrice,
});
if (!stripePrice) {
logger.info("Creating stripe fixed price");
await createStripeFixedPrice({
sb,
stripeCli,
price,
product,
org,
curStripePrice: stripePrice,
});
}
}
// 2. If prepaid
@@ -161,6 +167,23 @@ export const createStripePriceIFNotExist = async ({
org,
curStripeProd: stripeProd,
});
} else if (!config.stripe_placeholder_price_id) {
logger.info(`Creating stripe placeholder price`);
let placeholderPrice = await createStripeMeteredPrice({
sb,
stripeCli,
price,
entitlements,
product,
org,
});
config.stripe_placeholder_price_id = placeholderPrice.id;
await sb
.from("prices")
.update({
config,
})
.eq("id", price.id);
}
}

View File

@@ -12,6 +12,7 @@ export const priceToInArrearProrated = ({
}) => {
const config = price.config as UsagePriceConfig;
let quantity = existingUsage || 0;
if (quantity == 0 && isCheckout) {
return {
price: config.stripe_placeholder_price_id,

View File

@@ -0,0 +1,4 @@
export const stripeErrToCusMsg = (error: any) => {
let code = error.code;
let msg = error.message;
};

View File

@@ -1 +1,49 @@
import { Customer, Feature } from "@autumn/shared";
import { getBillingType } from "@/internal/prices/priceUtils.js";
import { BillingType, Price, UsagePriceConfig } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
export const submitUsageToStripe = async ({
price,
stripeCli,
usage,
customer,
usageTimestamp,
feature,
logger,
}: {
stripeCli: Stripe;
price: Price;
usage: number;
customer: Customer;
usageTimestamp?: number;
feature: Feature;
logger: any;
}) => {
let config = price.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (billingType != BillingType.UsageInArrear) {
logger.warn(
`Price ${price.id} is not usage in arrear type, can't send usage`
);
}
const stripeMeter = await stripeCli.billing.meters.retrieve(
config.stripe_meter_id!
);
await stripeCli.billing.meterEvents.create({
// event_name: price.id!,
event_name: stripeMeter.event_name,
payload: {
stripe_customer_id: customer.processor.id,
value: usage.toString(),
},
timestamp: usageTimestamp || Math.floor(Date.now() / 1000),
});
logger.info(
`🌟🌟🌟 Submitted meter event for customer ${customer.id}, feature: ${feature.name}, usage: ${usage}`
);
};

View File

@@ -4,6 +4,10 @@ import {
Price,
AllowanceType,
Feature,
Customer,
Organization,
FullCusProduct,
UsagePriceConfig,
} from "@autumn/shared";
import Stripe from "stripe";
@@ -13,6 +17,7 @@ import {
getEntOptions,
getPriceAmount,
getPriceEntitlement,
getPriceForOverage,
getPriceOptions,
getProductForPrice,
} from "@/internal/prices/priceUtils.js";
@@ -21,6 +26,7 @@ import { SupabaseClient } from "@supabase/supabase-js";
import { AttachParams } from "@/internal/customers/products/AttachParams.js";
import { getExistingUsageFromCusProducts } from "@/internal/customers/entitlements/cusEntUtils.js";
import { priceToStripeItem } from "./priceToStripeItem/priceToStripeItem.js";
import { getFeatureName } from "@/internal/features/displayUtils.js";
export const createSubMeta = ({ features }: { features: Feature[] }) => {
const usageFeatures = features.map((f) => ({
@@ -118,6 +124,7 @@ export const getStripeSubItems = async ({
cusProducts: attachParams.cusProducts,
entities: attachParams.entities,
carryExistingUsages,
internalEntityId: attachParams.internalEntityId,
});
if (
@@ -179,48 +186,64 @@ export const getStripeSubItems = async ({
return itemSets;
};
// Can delete
export const pricesToInvoiceItems = async ({
sb,
stripeCli,
attachParams,
export const getInvoiceItemForUsage = ({
stripeInvoiceId,
price,
feature,
totalUsage,
overage,
currency,
customer,
cusProduct,
logger,
periodStart,
periodEnd,
}: {
sb: SupabaseClient;
stripeCli: Stripe;
attachParams: AttachParams;
stripeInvoiceId: string;
price: Price;
feature: Feature;
totalUsage: number;
overage: number;
currency: string;
customer: Customer;
cusProduct: FullCusProduct;
logger: any;
periodStart: number;
periodEnd: number;
}) => {
const { prices, optionsList, entitlements, products, customer } =
attachParams;
for (const price of prices) {
// Calculate amount
const options = getPriceOptions(price, optionsList);
const entitlement = getPriceEntitlement(price, entitlements);
const amount = getPriceAmount({
price,
options,
relatedEnt: entitlement,
});
let priceAmount = getPriceForOverage(price, overage);
let featureName = getFeatureName({
feature,
plural: totalUsage == 1 ? false : true,
capitalize: true,
});
let allowanceStr = "";
if (entitlement) {
allowanceStr =
entitlement.allowance_type == AllowanceType.Unlimited
? "Unlimited"
: entitlement.allowance_type == AllowanceType.None
? "None"
: `${entitlement.allowance}`;
allowanceStr = `x ${allowanceStr} (${entitlement.feature.name})`;
}
let config = price.config! as UsagePriceConfig;
let invoiceItem: Stripe.InvoiceItemCreateParams = {
invoice: stripeInvoiceId,
customer: customer.processor.id,
currency,
let product = getProductForPrice(price, products)!;
description: `${cusProduct.product.name} - ${featureName} x ${Math.round(
totalUsage
)}`,
await stripeCli.invoiceItems.create({
customer: customer.processor.id,
amount: amount * 100,
invoice: stripeInvoiceId,
description: `${product.name}${allowanceStr}`,
});
}
price_data: {
product: config.stripe_product_id!,
unit_amount: Math.max(Math.round(priceAmount * 100), 0),
currency,
},
period: {
start: periodStart,
end: periodEnd,
},
};
logger.info(
`🌟🌟 Created invoice item for ${
feature.name
} usage. Amount: ${priceAmount.toFixed(2)}, Total Usage: ${totalUsage}`
);
return invoiceItem;
};

View File

@@ -76,6 +76,7 @@ stripeWebhookRouter.post(
org,
subscription: event.data.object,
env,
logger,
});
break;

View File

@@ -72,6 +72,18 @@ export const calculateMetered1Price = ({
return totalPrice;
};
export const subToAutumnInterval = (sub: Stripe.Subscription) => {
let recuringItem = sub.items.data.find((i) => i.price.recurring != null);
if (!recuringItem) {
return BillingInterval.OneOff;
}
return stripeToAutumnInterval({
interval: recuringItem.price.recurring!.interval,
intervalCount: recuringItem.price.recurring!.interval_count,
});
};
export const stripeToAutumnInterval = ({
interval,
intervalCount,

View File

@@ -30,6 +30,10 @@ import { getResetBalancesUpdate } from "@/internal/customers/entitlements/groupB
import { createLogtailWithContext } from "@/external/logtail/logtailUtils.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
import { Client } from "pg";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { getFeatureName } from "@/internal/features/displayUtils.js";
import { submitUsageToStripe } from "../stripeMeterUtils.js";
import { getInvoiceItemForUsage } from "../stripePriceUtils.js";
// Format invoice nicely
@@ -239,16 +243,19 @@ const handleUsageInArrear = async ({
return;
}
const balance = getTotalNegativeBalance({
const totalNegativeBalance = getTotalNegativeBalance({
cusEnt: relatedCusEnt as any,
balance: relatedCusEnt.balance!,
entities: relatedCusEnt.entities!,
billingUnits: (price.config as UsagePriceConfig).billing_units || 1,
});
const totalQuantity = new Decimal(allowance).minus(balance).toNumber();
const totalQuantity = new Decimal(allowance)
.minus(totalNegativeBalance)
.toNumber();
const billingUnits = (price.config as UsagePriceConfig).billing_units || 1;
const roundedQuantity =
Math.ceil(new Decimal(totalQuantity).div(billingUnits).toNumber()) *
billingUnits;
@@ -257,43 +264,48 @@ const handleUsageInArrear = async ({
subDays(new Date(invoice.created * 1000), 1).getTime() / 1000
);
// 1. Get stripe meter
const stripeMeter = await stripeCli.billing.meters.retrieve(
config.stripe_meter_id!
);
await stripeCli.billing.meterEvents.create({
// event_name: price.id!,
event_name: stripeMeter.event_name,
payload: {
stripe_customer_id: customer.processor.id,
value: roundedQuantity.toString(),
},
timestamp: usageTimestamp,
});
let feature = relatedCusEnt.entitlement.feature;
logger.info(
`✅ Submitted meter event for customer ${customer.id}, feature: ${feature.id}, stripe event: ${stripeMeter.event_name}`
);
logger.info(
`Allowance: ${allowance}, Balance: ${balance}, Quantity: ${totalQuantity}, Rounded: ${roundedQuantity}`
);
if (activeProduct.internal_entity_id) {
let currency = invoice.currency;
// console.log("Total negative balance: ", totalNegativeBalance);
// console.log("Total quantity: ", roundedQuantity);
let invoiceItem = getInvoiceItemForUsage({
stripeInvoiceId: invoice.id,
price,
overage: -totalNegativeBalance,
customer,
currency,
cusProduct: activeProduct,
feature,
totalUsage: totalQuantity,
logger,
periodStart: invoice.period_start,
periodEnd: invoice.period_end,
});
let invoiceCreatedStr = formatUnixToDateTime(invoice.created * 1000);
let usageTimestampStr = formatUnixToDateTime(usageTimestamp * 1000);
logger.info(
`Invoice created: ${invoiceCreatedStr}, Usage timestamp: ${usageTimestampStr}`
);
await stripeCli.invoiceItems.create(invoiceItem);
} else {
await submitUsageToStripe({
price,
stripeCli,
usage: roundedQuantity,
customer,
usageTimestamp,
feature: relatedCusEnt.entitlement.feature,
logger,
});
}
// let invoiceCreatedStr = formatUnixToDateTime(invoice.created * 1000);
// let usageTimestampStr = formatUnixToDateTime(usageTimestamp * 1000);
// logger.info(
// `Invoice created: ${invoiceCreatedStr}, Usage timestamp: ${usageTimestampStr}`
// );
// reset balance
// TODO: If lifetime, reset to 0...
if (relatedCusEnt.entitlement.interval == EntInterval.Lifetime) {
logger.info(
`Feature ${feature.id} has lifetime interval, skipping reset...`
);
return;
}
let ent = relatedCusEnt.entitlement;
let resetBalancesUpdate = getResetBalancesUpdate({
cusEnt: relatedCusEnt,
@@ -480,6 +492,52 @@ export const handleInvoiceCreated = async ({
return;
}
let internalEntityId = activeProducts.find(
(p) => p.internal_entity_id
)?.internal_entity_id;
let features = await FeatureService.getFeatures({
sb,
orgId: org.id,
env,
});
if (internalEntityId) {
// Add memo to invoice
try {
let stripeCli = createStripeCli({ org, env });
let entity = await EntityService.getByInternalId({
sb,
internalId: internalEntityId,
orgId: org.id,
env,
});
if (entity) {
logger.info(`Entity: ${entity.name}`);
}
// Add memo to invoice
let feature = features.find(
(f) => f.internal_id == entity?.internal_feature_id
);
await stripeCli.invoices.update(invoice.id, {
description: `${getFeatureName({
feature,
plural: false,
capitalize: true,
})}: ${entity?.name} (ID: ${entity?.id})`,
});
} catch (error: any) {
if (
error.message != "Finalized invoices can't be updated in this way"
) {
logger.error(`Failed to add entity ID to invoice description`, error);
}
}
}
const stripeSubs = await getStripeSubs({
stripeCli: createStripeCli({ org, env }),
subIds: activeProducts.map((p) => p.subscription_ids).flat(),
@@ -514,3 +572,20 @@ export const handleInvoiceCreated = async ({
// });
// continue;
// }
// // 1. Get stripe meter
// const stripeMeter = await stripeCli.billing.meters.retrieve(
// config.stripe_meter_id!
// );
// await stripeCli.billing.meterEvents.create({
// // event_name: price.id!,
// event_name: stripeMeter.event_name,
// payload: {
// stripe_customer_id: customer.processor.id,
// value: roundedQuantity.toString(),
// },
// timestamp: usageTimestamp,
// });
// let feature = relatedCusEnt.entitlement.feature;

View File

@@ -1,5 +1,12 @@
import { CusProductService } from "@/internal/customers/products/CusProductService.js";
import { CusProductStatus, Organization, ProcessorType } from "@autumn/shared";
import {
BillingType,
CusProductStatus,
FullCusProduct,
Organization,
Price,
ProcessorType,
} from "@autumn/shared";
import { AppEnv } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
@@ -8,17 +15,20 @@ import { InvoiceService } from "@/internal/customers/invoices/InvoiceService.js"
import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import { getBillingType } from "@/internal/prices/priceUtils.js";
export const handleSubCreated = async ({
sb,
subscription,
org,
env,
logger,
}: {
sb: SupabaseClient;
subscription: Stripe.Subscription;
org: Organization;
env: AppEnv;
logger: any;
}) => {
if (subscription.schedule) {
const cusProds = await CusProductService.getByStripeScheduledId({
@@ -115,4 +125,71 @@ export const handleSubCreated = async ({
await Promise.all(batchUpdate);
}
// Get cus prods for sub
let cusProds = await CusProductService.getByStripeSubId({
sb,
stripeSubId: subscription.id,
orgId: org.id,
env,
withCusEnts: true,
withCusPrices: true,
});
let stripeCli = createStripeCli({ org, env });
let handleInArrearWithEntity = async (cusProd: FullCusProduct) => {
if (!cusProd.internal_entity_id) {
return;
}
let arrearPrices = cusProd.customer_prices
.map((cp) => cp.price)
.filter(
(p: Price) =>
getBillingType(p.config as any) == BillingType.UsageInArrear
);
if (arrearPrices.length == 0) {
return;
}
let itemsToDelete = [];
for (const arrearPrice of arrearPrices) {
let subItem = subscription.items.data.find(
(i) => i.price.id == arrearPrice.config?.stripe_price_id
);
if (!subItem) {
continue;
}
itemsToDelete.push({
id: subItem.id,
deleted: true,
});
}
if (itemsToDelete.length > 0) {
try {
await stripeCli.subscriptions.update(subscription.id, {
items: itemsToDelete,
});
console.log(
`sub.created, cus product with entity: deleted ${itemsToDelete.length} items`
);
} catch (error) {
logger.error(
`sub.created, cus product with entity: failed to delete items`,
error
);
}
}
};
let batchUpdate = [];
for (const cusProd of cusProds) {
batchUpdate.push(handleInArrearWithEntity(cusProd));
}
await Promise.all(batchUpdate);
};

View File

@@ -106,7 +106,7 @@ apiRouter.use("/redemptions", redemptionRouter);
// Cus Product
apiRouter.use(attachRouter);
apiRouter.use("/expire", expireRouter);
apiRouter.use("/cancel", expireRouter);
apiRouter.use("/entitled", entitledRouter);
apiRouter.use("/check", entitledRouter);

View File

@@ -5,6 +5,7 @@ import {
Customer,
CustomerData,
CustomerSchema,
FullCustomer,
FullCustomerEntitlement,
Organization,
ProductSchema,
@@ -25,6 +26,7 @@ import {
import { processInvoice } from "@/internal/customers/invoices/InvoiceService.js";
import { InvoiceService } from "@/internal/customers/invoices/InvoiceService.js";
import { handleCreateCustomer } from "./handlers/handleCreateCustomer.js";
import { sortCusEntsForDeduction } from "@/internal/customers/entitlements/cusEntUtils.js";
export const updateCustomerDetails = async ({
sb,
@@ -191,58 +193,67 @@ export const processFullCusProducts = ({
// IMPORTANT FUNCTION
export const getCusEntsInFeatures = async ({
sb,
internalCustomerId,
customer,
internalFeatureIds,
inStatuses = [CusProductStatus.Active],
withPrices = false,
withProduct = false,
logger,
reverseOrder = false,
}: {
sb: SupabaseClient;
internalCustomerId: string;
customer: FullCustomer;
internalFeatureIds?: string[];
inStatuses?: CusProductStatus[];
withPrices?: boolean;
withProduct?: boolean;
logger: any;
reverseOrder?: boolean;
}) => {
const fullCusProducts = await CusService.getFullCusProducts({
sb,
internalCustomerId,
inStatuses: inStatuses,
withPrices: withPrices,
withProduct: withProduct,
logger,
let cusProducts = customer.customer_products;
let cusEnts = cusProducts.flatMap((cusProduct) => {
return cusProduct.customer_entitlements.map((cusEnt) => ({
...cusEnt,
customer_product: cusProduct,
}));
});
const cusEntsWithCusProduct = fullCusProductToCusEnts(
fullCusProducts!,
inStatuses,
reverseOrder
);
let cusPrices = cusProducts.flatMap((cusProduct) => {
return cusProduct.customer_prices || [];
});
if (!cusEntsWithCusProduct) {
return { cusEnts: [] };
}
let cusEnts: FullCustomerEntitlement[] = [];
if (internalFeatureIds) {
cusEnts = cusEntsWithCusProduct.filter((cusEnt) =>
cusEnts = cusEnts.filter((cusEnt) =>
internalFeatureIds.includes(cusEnt.internal_feature_id)
);
} else {
cusEnts = cusEntsWithCusProduct;
}
// sortCusEntsForDeduction(cusEnts, reverseOrder);
if (!withPrices) {
return { cusEnts, cusPrices: undefined };
if (customer.entity) {
let entity = customer.entity;
cusEnts = cusEnts.filter(
(cusEnt) =>
cusEnt.customer_product.internal_entity_id === entity.internal_id ||
cusEnt.entities
);
}
const cusPrices = fullCusProductToCusPrices(fullCusProducts, inStatuses);
sortCusEntsForDeduction(cusEnts, reverseOrder);
// // const cusEntsWithCusProduct = fullCusProductToCusEnts(
// // customer.customer_products,
// // [CusProductStatus.Active, CusProductStatus.PastDue],
// // reverseOrder
// // );
// if (!cusEntsWithCusProduct) {
// return { cusEnts: [] };
// }
// let cusEnts: FullCustomerEntitlement[] = [];
// if (internalFeatureIds) {
// cusEnts = cusEntsWithCusProduct.filter((cusEnt) =>
// internalFeatureIds.includes(cusEnt.internal_feature_id)
// );
// } else {
// cusEnts = cusEntsWithCusProduct;
// }
// // sortCusEntsForDeduction(cusEnts, reverseOrder);
// const cusPrices = fullCusProductToCusPrices(fullCusProducts, inStatuses);
return { cusEnts, cusPrices };
};

View File

@@ -75,6 +75,7 @@ export const createNewCustomer = async ({
nextResetAt,
processor,
logger,
createDefaultProducts = true,
}: {
sb: SupabaseClient;
org: Organization;
@@ -83,6 +84,7 @@ export const createNewCustomer = async ({
nextResetAt?: number;
processor?: any;
logger: any;
createDefaultProducts?: boolean;
}) => {
logger.info(`Creating new customer: ${customer.id}`);
logger.info(`Org ID: ${org.id}`);
@@ -137,6 +139,10 @@ export const createNewCustomer = async ({
customer: customerData,
});
if (!createDefaultProducts) {
return newCustomer;
}
if (nonFreeProds.length > 0) {
await initStripeCusAndProducts({
sb,
@@ -202,6 +208,7 @@ const handleIdIsNull = async ({
newCus,
logger,
processor,
createDefaultProducts,
}: {
sb: SupabaseClient;
org: Organization;
@@ -209,6 +216,7 @@ const handleIdIsNull = async ({
newCus: CreateCustomer;
logger: any;
processor?: any;
createDefaultProducts?: boolean;
}) => {
// 1. ID is null
if (!newCus.email) {
@@ -252,6 +260,7 @@ const handleIdIsNull = async ({
customer: newCus,
logger,
processor,
createDefaultProducts,
});
return createdCustomer;
@@ -265,6 +274,7 @@ export const handleCreateCustomerWithId = async ({
logger,
newCus,
processor,
createDefaultProducts = true,
}: {
sb: SupabaseClient;
org: Organization;
@@ -272,6 +282,7 @@ export const handleCreateCustomerWithId = async ({
logger: any;
newCus: CreateCustomer;
processor?: any;
createDefaultProducts?: boolean;
}) => {
// 1. Get by ID
let existingCustomer = await CusService.getById({
@@ -327,6 +338,7 @@ export const handleCreateCustomerWithId = async ({
customer: newCus,
logger,
processor,
createDefaultProducts,
});
};
@@ -339,6 +351,7 @@ export const handleCreateCustomer = async ({
params = {},
processor,
getDetails = true,
createDefaultProducts = true,
}: {
cusData: CreateCustomer;
sb: SupabaseClient;
@@ -348,6 +361,7 @@ export const handleCreateCustomer = async ({
params?: any;
processor?: any;
getDetails?: boolean;
createDefaultProducts?: boolean;
}) => {
const newCus = CreateCustomerSchema.parse(cusData);
@@ -361,6 +375,7 @@ export const handleCreateCustomer = async ({
newCus,
logger,
processor,
createDefaultProducts,
});
} else {
createdCustomer = await handleCreateCustomerWithId({
@@ -370,6 +385,7 @@ export const handleCreateCustomer = async ({
logger,
newCus,
processor,
createDefaultProducts,
});
}

View File

@@ -8,6 +8,7 @@ import {
fullCusProductToProduct,
} from "@/internal/customers/products/cusProductUtils.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { isOneOff } from "@/internal/products/productUtils.js";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import {
ErrCode,
@@ -15,14 +16,15 @@ import {
FullCusProduct,
Organization,
AppEnv,
FullCustomer,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { StatusCodes } from "http-status-codes";
export const expireCusProduct = async ({
export const removeScheduledProduct = async ({
sb,
cusProduct, // cus product to expire
cusProducts, // other cus products
cusProduct,
cusProducts,
org,
env,
logger,
@@ -33,10 +35,57 @@ export const expireCusProduct = async ({
org: Organization;
env: AppEnv;
logger: any;
}) => {
const stripeCli = createStripeCli({ org: org, env: env });
// Get full product from cus product
let fullProduct = fullCusProductToProduct(cusProduct);
// 1. Cancel future product schedule
await cancelFutureProductSchedule({
sb,
org,
cusProducts,
product: fullProduct,
stripeCli,
logger,
env,
});
// 2. Delete scheduled product
await CusProductService.delete({
sb,
cusProductId: cusProduct.id,
});
return;
};
export const expireCusProduct = async ({
sb,
cusProduct, // cus product to expire
cusProducts, // other cus products
org,
env,
logger,
customer,
expireImmediately = true,
}: {
sb: SupabaseClient;
cusProduct: FullCusProduct;
cusProducts: FullCusProduct[];
org: Organization;
env: AppEnv;
logger: any;
customer: FullCustomer;
expireImmediately: boolean;
}) => {
logger.info("--------------------------------");
logger.info(`🔔 Handling CusProduct Expired`);
logger.info(`Customer: ${cusProduct.customer.id} (${env}), Org: ${org.id}`);
logger.info(
`🔔 Expiring cutomer product (${
expireImmediately ? "immediately" : "end of cycle"
})`
);
logger.info(`Customer: ${customer.id} (${env}), Org: ${org.id}`);
logger.info(
`Product: ${cusProduct.product.name}, Status: ${cusProduct.status}`
);
@@ -56,50 +105,22 @@ export const expireCusProduct = async ({
// If current product is scheduled
if (cusProduct.status == CusProductStatus.Scheduled) {
const stripeCli = createStripeCli({ org: org, env: env });
// Get full product from cus product
let fullProduct = fullCusProductToProduct(cusProduct);
// 1. Cancel future product schedule
await cancelFutureProductSchedule({
await removeScheduledProduct({
sb,
org,
cusProduct,
cusProducts,
product: fullProduct,
stripeCli,
logger,
org,
env,
logger,
});
}
// 2. Delete scheduled product
await CusProductService.delete({
sb,
cusProductId: cusProduct.id,
});
} else {
if (cusProduct.product.is_add_on) {
await cancelCusProductSubscriptions({
sb,
cusProduct,
org,
env,
});
await CusProductService.update({
sb,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: Date.now(),
},
});
return;
}
// 1. If main product, can't expire if there's scheduled product
let isMain = !cusProduct.product.is_add_on;
if (isMain) {
const futureProduct = await CusProductService.getFutureProduct({
sb,
internalCustomerId: cusProduct.customer.internal_id,
internalCustomerId: customer.internal_id,
productGroup: cusProduct.product.group,
});
@@ -110,25 +131,67 @@ export const expireCusProduct = async ({
statusCode: StatusCodes.BAD_REQUEST,
});
}
// For regular products
// 1. Cancel stripe subscriptions
const cancelled = await cancelCusProductSubscriptions({
}
// 2. If expire at cycle end, just cancel subscriptions
if (!expireImmediately) {
await cancelCusProductSubscriptions({
sb,
cusProduct,
org,
env,
expireImmediately,
});
if (isOneOff(cusProduct.customer_prices.map((p) => p.price))) {
await CusProductService.update({
sb,
cusProductId: cusProduct.id,
updates: { status: CusProductStatus.Expired },
});
}
return;
}
if (cusProduct.product.is_add_on) {
await cancelCusProductSubscriptions({
sb,
cusProduct,
org,
env,
});
if (!cancelled) {
await expireAndActivate({
sb,
env,
cusProduct,
org,
});
} // else will be handled by webhook
await CusProductService.update({
sb,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: Date.now(),
},
});
return;
}
// For regular products
// 1. Cancel stripe subscriptions
const cancelled = await cancelCusProductSubscriptions({
sb,
cusProduct,
org,
env,
});
if (!cancelled) {
await expireAndActivate({
sb,
env,
cusProduct,
org,
});
} // else will be handled by webhook
return;
};
@@ -162,6 +225,8 @@ export const handleCusProductExpired = async (req: any, res: any) => {
org,
env: req.env,
logger: req.logtail,
customer: cusProduct.customer,
expireImmediately: true,
});
res.status(200).json({ message: "Product expired" });

View File

@@ -25,11 +25,12 @@ import { notNullish } from "@/utils/genUtils.js";
const getCusFeaturesAndOrg = async (req: any, customerId: string) => {
// 1. Get customer
const [customer, features, org] = await Promise.all([
CusService.getByIdOrInternalId({
CusService.getWithProducts({
sb: req.sb,
idOrInternalId: customerId,
orgId: req.orgId,
env: req.env,
entityId: req.params.entity_id,
}),
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
@@ -79,10 +80,8 @@ export const handleUpdateBalances = async (req: any, res: any) => {
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
sb: req.sb,
internalCustomerId: customer.internal_id,
customer,
internalFeatureIds: featuresToUpdate.map((f) => f.internal_id!),
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
withPrices: true,
logger: req.logtail,
});

View File

@@ -256,7 +256,6 @@ export const checkStripeConnections = async ({
);
}
await Promise.all(batchPriceUpdates);
// throw new Error("test");
};
export const customerHasPm = async ({
@@ -279,6 +278,7 @@ attachRouter.post("/attach", async (req: any, res) => {
const {
customer_id,
product_id,
entity_id,
customer_data,
is_custom,
@@ -333,6 +333,7 @@ attachRouter.post("/attach", async (req: any, res) => {
sb,
customerId: customer_id,
productId: product_id,
entityId: entity_id,
customerData: customer_data,
org,
orgId: org.id,

View File

@@ -15,28 +15,36 @@ expireRouter.post("", async (req, res) =>
action: "expire",
handler: async (req, res) => {
let { sb, orgId, env, logtail: logger } = req;
let { customer_id, product_id } = req.body;
let { customer_id, product_id, entity_id, cancel_immediately } = req.body;
let expireImmediately = cancel_immediately || false;
let [customer, org] = await Promise.all([
CusService.getById({ sb, orgId, id: customer_id, env, logger }),
CusService.getWithProducts({
sb,
orgId,
idOrInternalId: customer_id,
env,
withEntities: true,
entityId: entity_id,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
}),
OrgService.getFromReq(req),
]);
let cusProducts = await CusService.getFullCusProducts({
sb,
internalCustomerId: customer.internal_id,
withProduct: true,
withPrices: true,
logger,
inStatuses: [CusProductStatus.Active, CusProductStatus.Scheduled],
});
for (const cusProduct of cusProducts) {
cusProduct.customer = customer;
if (entity_id && !customer.entity) {
throw new RecaseError({
code: ErrCode.EntityNotFound,
message: `Entity ${entity_id} not found for customer ${customer_id}`,
});
}
let cusProducts = customer.customer_products;
let cusProductsToExpire = cusProducts.filter(
(cusProduct: FullCusProduct) => cusProduct.product.id == product_id
(cusProduct: FullCusProduct) =>
cusProduct.product.id == product_id &&
(entity_id ? cusProduct.entity_id == entity_id : true)
);
if (cusProductsToExpire.length == 0) {
@@ -54,6 +62,8 @@ expireRouter.post("", async (req, res) =>
org,
env,
logger,
customer,
expireImmediately,
});
}

View File

@@ -41,6 +41,32 @@ export class EntityService {
return data;
}
static async getByInternalId({
sb,
internalId,
orgId,
env,
}: {
sb: SupabaseClient;
internalId: string;
orgId: string;
env: string;
}) {
const { data, error } = await sb
.from("entities")
.select("*")
.eq("internal_id", internalId)
.eq("org_id", orgId)
.eq("env", env)
.single();
if (error) {
throw error;
}
return data;
}
static async get({
sb,
orgId,

View File

@@ -1,16 +1,23 @@
import { submitUsageToStripe } from "@/external/stripe/stripeMeterUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js";
import {
getBillingInterval,
getBillingType,
roundUsage,
} from "@/internal/prices/priceUtils.js";
import { notNullish } from "@/utils/genUtils.js";
import {
AppEnv,
BillingType,
Customer,
Entitlement,
Entity,
Feature,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
@@ -78,12 +85,18 @@ export const removeEntityFromCusEnt = async ({
entity,
logger,
cusPrice,
customer,
org,
env,
}: {
sb: SupabaseClient;
cusEnt: FullCustomerEntitlement;
entity: Entity;
logger: any;
cusPrice?: FullCustomerPrice;
customer: Customer;
org: Organization;
env: AppEnv;
}) => {
// isLinked
let isLinked = isLinkedToEntity({
@@ -104,9 +117,30 @@ export const removeEntityFromCusEnt = async ({
let newEntities = structuredClone(cusEnt.entities!);
// TODO: Send usage to stripe if cus price exists
let stripeCli = createStripeCli({
org,
env,
});
if (cusPrice) {
let billingType = getBillingType(cusPrice.price.config!);
console.log("Usage to send:", -newEntities[entity.id]?.balance);
let config = cusPrice.price.config as UsagePriceConfig;
let billingType = getBillingType(config);
if (billingType == BillingType.UsageInArrear) {
let usage = -newEntities[entity.id]?.balance;
usage = roundUsage({
usage,
billingUnits: config.billing_units!,
});
await submitUsageToStripe({
price: cusPrice.price,
usage,
customer,
feature: entitlement.feature,
logger,
stripeCli,
});
}
}
delete newEntities[entity.id];

View File

@@ -157,6 +157,7 @@ export const handleCreateEntity = async (req: any, res: any) => {
orgId,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
withEntities: true,
}),
FeatureService.getFromReq(req),
OrgService.getFromReq(req),
@@ -195,15 +196,7 @@ export const handleCreateEntity = async (req: any, res: any) => {
}
let cusProducts = await customer.customer_products;
// Fetch existing
let existingEntities = await EntityService.get({
sb,
orgId,
env,
internalFeatureId: feature.internal_id,
internalCustomerId: customer.internal_id,
});
let existingEntities = customer.entities;
logger.info("Existing entities:");
logger.info(
@@ -275,10 +268,6 @@ export const handleCreateEntity = async (req: any, res: any) => {
const newBalance =
cusEnt.balance - (newCount + replacedCount) + (unused || 0);
// console.log("originalBalance", originalBalance);
// console.log("newBalance", newBalance);
// throw new Error("test");
await adjustAllowance({
sb,
env,
@@ -292,6 +281,7 @@ export const handleCreateEntity = async (req: any, res: any) => {
deduction: newCount + replacedCount,
product,
replacedCount,
fromEntities: true,
});
await req.pg.query(

View File

@@ -145,6 +145,9 @@ export const handleDeleteEntity = async (req: any, res: any) => {
entity,
logger,
cusPrice: relatedCusPrice,
customer,
org,
env,
});
}

View File

@@ -101,8 +101,6 @@ const getMeteredEntitledResult = ({
// 1. Skip if feature not among cusEnt
if (!cusEntsContainFeature({ cusEnts, feature })) {
console.log("Feature not found", feature.id);
continue;
}
@@ -246,7 +244,8 @@ const getCusEntsAndFeatures = async ({
sb: SupabaseClient;
logger: any;
}) => {
let { customer_id, feature_id, customer_data } = req.body;
let { customer_id, feature_id, customer_data, entity_id } = req.body;
let { sb, orgId, env } = req;
// 1. Get org and features
@@ -267,9 +266,18 @@ const getCusEntsAndFeatures = async ({
customerData: customer_data,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
logger,
entityId: entity_id,
}),
]);
if (entity_id && !customer.entity) {
throw new RecaseError({
message: `Entity ${entity_id} not found for customer ${customer_id}`,
code: ErrCode.EntityNotFound,
statusCode: StatusCodes.BAD_REQUEST,
});
}
const { feature, creditSystems, allFeatures } = featureRes;
const duration = Date.now() - startTime;
@@ -300,7 +308,25 @@ const getCusEntsAndFeatures = async ({
});
});
return { cusEnts, feature, creditSystems, org, cusProducts, allFeatures };
if (customer.entity) {
cusEnts = cusEnts.filter((cusEnt) => {
return (
notNullish(cusEnt.entities) ||
cusEnt.customer_product.internal_entity_id ===
customer.entity.internal_id
);
});
}
return {
cusEnts,
feature,
creditSystems,
org,
cusProducts,
allFeatures,
entity: customer.entity,
};
};
entitledRouter.post("", async (req: any, res: any) => {

View File

@@ -3,11 +3,13 @@ import {
APIVersion,
AppEnv,
CreateEventSchema,
CusProductStatus,
Customer,
ErrCode,
Event,
Feature,
FeatureType,
FullCustomer,
Organization,
} from "@autumn/shared";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
@@ -24,6 +26,8 @@ import { StatusCodes } from "http-status-codes";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
import { JobName } from "@/queue/JobName.js";
export const eventsRouter = Router();
@@ -35,6 +39,7 @@ const getEventAndCustomer = async ({
customer_data,
event_data,
logger,
entityId,
}: {
sb: SupabaseClient;
org: Organization;
@@ -42,6 +47,7 @@ const getEventAndCustomer = async ({
customer_id: string;
customer_data: any;
event_data: any;
entityId: string;
logger: any;
}) => {
if (!customer_id) {
@@ -52,7 +58,7 @@ const getEventAndCustomer = async ({
});
}
let customer: Customer;
let customer: FullCustomer;
// 2. Check if customer ID is valid
customer = await getOrCreateCustomer({
@@ -62,6 +68,8 @@ const getEventAndCustomer = async ({
customerId: customer_id,
customerData: customer_data,
logger,
entityId,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
});
// 3. Insert event
@@ -92,16 +100,10 @@ const getEventAndCustomer = async ({
const getAffectedFeatures = async ({
req,
pg,
event,
orgId,
env,
}: {
req: any;
pg: Client;
event: Event;
orgId: string;
env: AppEnv;
}) => {
let features = await FeatureService.getFromReq(req);
@@ -127,27 +129,6 @@ const getAffectedFeatures = async ({
});
return [...featuresWithEvent, ...creditSystems];
// const { rows }: { rows: Feature[] } = await pg.query(`
// with features_with_event as (
// select * from features
// where org_id = '${orgId}'
// and env = '${env}'
// and config -> 'filters' @> '[{"value": ["${event.event_name}"]}]'::jsonb
// )
// select * from features WHERE
// org_id = '${orgId}'
// and env = '${env}'
// and EXISTS (
// SELECT 1 FROM jsonb_array_elements(config->'schema') as schema_element WHERE
// schema_element->>'metered_feature_id' IN (SELECT id FROM features_with_event)
// )
// UNION all
// select * from features_with_event
// `);
// return rows;
};
export const handleEventSent = async ({
@@ -179,14 +160,12 @@ export const handleEventSent = async ({
customer_data,
event_data,
logger: req.logtail,
entityId: event_data.entity_id,
});
const affectedFeatures = await getAffectedFeatures({
req,
pg: pg,
event,
orgId,
env,
});
if (affectedFeatures.length == 0) {
@@ -199,36 +178,19 @@ export const handleEventSent = async ({
if (affectedFeatures.length > 0) {
const payload = {
customerId: customer.internal_id,
customer,
internalCustomerId: customer.internal_id,
customerId: customer.id,
entityId: event_data.entity_id,
features: affectedFeatures,
event,
org,
env,
};
const queue = await QueueManager.getQueue({ useBackup: false });
try {
// Add timeout to queue operation
await queue.add("update-balance", payload);
// console.log("Added update-balance to queue");
} catch (error: any) {
try {
console.log("Adding update-balance to backup queue");
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await backupQueue.add("update-balance", payload);
} catch (error: any) {
throw new RecaseError({
message: "Failed to add update-balance to queue (backup)",
code: "EVENT_QUEUE_ERROR",
statusCode: 500,
data: {
message: error.message,
},
});
}
}
await addTaskToQueue({
jobName: JobName.UpdateBalance,
payload,
});
return { event, affectedFeatures, org };
}
@@ -267,6 +229,4 @@ eventsRouter.post("", async (req: any, res: any) => {
handleRequestError({ req, res, error, action: "POST event failed" });
return;
}
return;
});

View File

@@ -1,5 +1,11 @@
import { Router } from "express";
import { Customer, ErrCode, Event, FeatureType } from "@autumn/shared";
import {
CusProductStatus,
Customer,
ErrCode,
Event,
FeatureType,
} from "@autumn/shared";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { generateId, nullish } from "@/utils/genUtils.js";
@@ -12,6 +18,7 @@ import { QueueManager } from "@/queue/QueueManager.js";
import { JobName } from "@/queue/JobName.js";
import { getOrCreateCustomer } from "@/internal/customers/cusUtils/getOrCreateCustomer.js";
import { creditSystemContainsFeature } from "@/internal/features/creditSystemUtils.js";
import { addTaskToQueue } from "@/queue/queueUtils.js";
export const eventsRouter = Router();
export const usageRouter = Router();
@@ -19,11 +26,13 @@ const getCusFeatureAndOrg = async ({
req,
customerId,
featureId,
entityId,
customerData,
}: {
req: any;
customerId: string;
featureId: string;
entityId: string;
customerData: any;
}) => {
// 1. Get customer
@@ -36,6 +45,8 @@ const getCusFeatureAndOrg = async ({
customerId,
customerData,
logger: req.logtail,
entityId,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
}),
FeatureService.getFromReq(req),
]);
@@ -126,6 +137,7 @@ export const handleUsageEvent = async ({
customerId: customer_id,
featureId: feature_id,
customerData: customer_data,
entityId: entity_id,
});
let newEvent = await createAndInsertEvent({
@@ -148,8 +160,8 @@ export const handleUsageEvent = async ({
}
const payload = {
customerId: customer.internal_id,
customer,
customerId: customer.id,
internalCustomerId: customer.internal_id,
features,
org,
env: req.env,
@@ -159,25 +171,30 @@ export const handleUsageEvent = async ({
entityId: entity_id,
};
try {
// Add timeout to queue operation
await queue.add(JobName.UpdateUsage, payload);
} catch (error: any) {
try {
console.log("Adding update-balance to backup queue");
const backupQueue = await QueueManager.getQueue({ useBackup: true });
await backupQueue.add(JobName.UpdateUsage, payload);
} catch (error: any) {
throw new RecaseError({
message: "Failed to add update-usage to queue (backup)",
code: "EVENT_QUEUE_ERROR",
statusCode: 500,
data: {
message: error.message,
},
});
}
}
await addTaskToQueue({
jobName: JobName.UpdateUsage,
payload,
});
// try {
// // Add timeout to queue operation
// await queue.add(JobName.UpdateUsage, payload);
// } catch (error: any) {
// try {
// console.log("Adding update-balance to backup queue");
// const backupQueue = await QueueManager.getQueue({ useBackup: true });
// await backupQueue.add(JobName.UpdateUsage, payload);
// } catch (error: any) {
// throw new RecaseError({
// message: "Failed to add update-usage to queue (backup)",
// code: "EVENT_QUEUE_ERROR",
// statusCode: 500,
// data: {
// message: error.message,
// },
// });
// }
// }
return { event: newEvent, affectedFeatures: features, org };
};

View File

@@ -38,18 +38,24 @@ export class CusService {
CusProductStatus.PastDue,
CusProductStatus.Scheduled,
],
withEntities = false,
entityId,
}: {
sb: SupabaseClient;
idOrInternalId: string;
orgId: string;
env: AppEnv;
inStatuses?: CusProductStatus[];
withEntities?: boolean;
entityId?: string;
}) {
const { data, error } = await sb.rpc("get_cus_with_products", {
p_cus_id: idOrInternalId,
p_org_id: orgId,
p_env: env,
p_statuses: inStatuses,
p_with_entities: withEntities,
p_entity_id: entityId,
});
if (error) {
@@ -60,7 +66,7 @@ export class CusService {
return null;
}
let { customer, products } = data;
let { customer, products, entities, entity } = data;
if (!products) {
products = [];
@@ -78,6 +84,8 @@ export class CusService {
return {
...customer,
customer_products: products,
entities: entities,
entity: entity,
};
}

View File

@@ -76,6 +76,8 @@ export const initCusProduct = ({
subscriptionIds,
subscriptionScheduleIds,
isCustom,
entityId,
internalEntityId,
}: {
customer: Customer;
product: FullProduct;
@@ -94,6 +96,8 @@ export const initCusProduct = ({
subscriptionIds?: string[];
subscriptionScheduleIds?: string[];
isCustom?: boolean;
entityId?: string;
internalEntityId?: string;
}) => {
let isFuture = startsAt && startsAt > Date.now();
@@ -133,6 +137,8 @@ export const initCusProduct = ({
scheduled_ids: subscriptionScheduleIds,
is_custom: isCustom || false,
quantity: 1,
internal_entity_id: internalEntityId,
entity_id: entityId,
};
};
@@ -191,18 +197,21 @@ export const expireOrDeleteCusProduct = async ({
startsAt,
product,
cusProducts,
internalEntityId,
}: {
sb: SupabaseClient;
startsAt?: number;
product: FullProduct;
cusProducts?: FullCusProduct[];
internalEntityId?: string;
}) => {
// 1. If startsAt
if (startsAt && startsAt > Date.now()) {
let curScheduledProduct = cusProducts?.find(
(cp) =>
cp.product.group === product.group &&
cp.status === CusProductStatus.Scheduled
cp.status === CusProductStatus.Scheduled &&
(internalEntityId ? cp.internal_entity_id === internalEntityId : true)
);
if (curScheduledProduct) {
@@ -215,6 +224,7 @@ export const expireOrDeleteCusProduct = async ({
let { curMainProduct } = await getExistingCusProducts({
product,
cusProducts: cusProducts as FullCusProduct[],
internalEntityId,
});
if (curMainProduct) {
@@ -234,11 +244,13 @@ export const getExistingCusProduct = async ({
cusProducts,
product,
internalCustomerId,
internalEntityId,
}: {
sb?: SupabaseClient;
cusProducts?: FullCusProduct[];
product: FullProduct;
internalCustomerId: string;
internalEntityId?: string;
}) => {
if (!cusProducts) {
cusProducts = await CusService.getFullCusProducts({
@@ -250,6 +262,7 @@ export const getExistingCusProduct = async ({
const { curMainProduct } = await getExistingCusProducts({
product,
cusProducts: cusProducts as FullCusProduct[],
internalEntityId,
});
return curMainProduct;
@@ -319,6 +332,7 @@ export const createFullCusProduct = async ({
cusProducts: attachParams.cusProducts,
product,
internalCustomerId: customer.internal_id,
internalEntityId: attachParams.internalEntityId,
});
} catch (error) {}
@@ -350,12 +364,6 @@ export const createFullCusProduct = async ({
for (const entitlement of entitlements) {
const options = getEntOptions(optionsList, entitlement);
const relatedPrice = getEntRelatedPrice(entitlement, prices);
// const existingCusEnt = curCusProduct?.customer_entitlements.find(
// (ce) => ce.internal_feature_id === entitlement.internal_feature_id
// );
// Update existing entitlement if one off
const cusEnt: any = initCusEntitlement({
entitlement,
customer,
@@ -424,6 +432,8 @@ export const createFullCusProduct = async ({
subscriptionIds,
subscriptionScheduleIds,
isCustom: attachParams.isCustom || false,
entityId: attachParams.entityId,
internalEntityId: attachParams.internalEntityId,
});
// Expire previous product if not one off
@@ -433,6 +443,7 @@ export const createFullCusProduct = async ({
startsAt,
product,
cusProducts: attachParams.cusProducts,
internalEntityId: attachParams.internalEntityId,
});
}

View File

@@ -12,7 +12,11 @@ import chalk from "chalk";
import { SupabaseClient } from "@supabase/supabase-js";
import { createFullCusProduct } from "../add-product/createFullCusProduct.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import {
createStripeCli,
stripeToAutumnInterval,
subToAutumnInterval,
} from "@/external/stripe/utils.js";
import { AttachParams, AttachResultSchema } from "../products/AttachParams.js";
import { getPriceAmount } from "../../prices/priceUtils.js";
import {
@@ -40,6 +44,8 @@ import {
getNextStartOfMonthUnix,
} from "@/internal/prices/billingIntervalUtils.js";
import { SuccessCode } from "@autumn/shared";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { nullish } from "@/utils/genUtils.js";
export const handleBillNowPrices = async ({
sb,
@@ -49,6 +55,7 @@ export const handleBillNowPrices = async ({
fromRequest = true,
carryExistingUsages = false,
shouldPreview = false,
disableMerge = false,
}: {
sb: any;
attachParams: AttachParams;
@@ -57,9 +64,11 @@ export const handleBillNowPrices = async ({
fromRequest?: boolean;
carryExistingUsages?: boolean;
shouldPreview?: boolean;
disableMerge?: boolean;
}) => {
const logger = req.logtail;
let { org, customer, products, freeTrial, invoiceOnly } = attachParams;
let { org, customer, products, freeTrial, invoiceOnly, cusProducts } =
attachParams;
if (attachParams.disableFreeTrial) {
freeTrial = null;
@@ -75,30 +84,46 @@ export const handleBillNowPrices = async ({
let subscriptions: Stripe.Subscription[] = [];
let invoiceIds: string[] = [];
// Merge billing cycles...
let mergeCusProduct =
!disableMerge && org.config.merge_billing_cycles
? cusProducts?.find((cp) =>
products.some((p) => p.group == cp.product.group)
)
: undefined;
let mergeSubs = await getStripeSubs({
stripeCli,
subIds: mergeCusProduct?.subscription_ids,
});
for (const itemSet of itemSets) {
if (itemSet.interval === BillingInterval.OneOff) {
continue;
}
const { items } = itemSet;
let mergeWithSub = mergeSubs.find(
(sub) => subToAutumnInterval(sub) == itemSet.interval
);
let subscription;
try {
// Should create 2 subscriptions
let billingCycleAnchorUnix;
if (org.config.anchor_start_of_month) {
billingCycleAnchorUnix = getNextStartOfMonthUnix(itemSet.interval);
}
if (attachParams.billingAnchor) {
// Add interval to now
billingCycleAnchorUnix = getAlignedIntervalUnix(
attachParams.billingAnchor,
itemSet.interval
);
}
if (mergeWithSub) {
billingCycleAnchorUnix = mergeWithSub.current_period_end * 1000;
}
subscription = await createStripeSub({
sb,
stripeCli,
@@ -410,6 +435,7 @@ export const handleAddProduct = async ({
fromRequest = true,
carryExistingUsages = false,
keepResetIntervals = false,
disableMerge = false,
}: {
req: {
sb: SupabaseClient;
@@ -420,6 +446,7 @@ export const handleAddProduct = async ({
fromRequest?: boolean;
carryExistingUsages?: boolean;
keepResetIntervals?: boolean;
disableMerge?: boolean;
}) => {
const logger = req.logtail;
const { customer, products, prices } = attachParams;
@@ -462,6 +489,7 @@ export const handleAddProduct = async ({
res,
fromRequest,
carryExistingUsages,
disableMerge,
});
return;

View File

@@ -29,9 +29,11 @@ import { ACTIVE_STATUSES } from "../products/CusProductService.js";
export const getExistingCusProducts = async ({
product,
cusProducts,
internalEntityId,
}: {
product: Product;
cusProducts: FullCusProduct[];
internalEntityId?: string;
}) => {
if (!cusProducts || cusProducts.length === 0) {
return {
@@ -41,25 +43,34 @@ export const getExistingCusProducts = async ({
};
}
let curMainProduct = cusProducts.find(
(cp: any) =>
cp.product.group === product.group &&
!cp.product.is_add_on &&
// ACTIVE_STATUSES.includes(cp.status) &&
(cp.status == CusProductStatus.Active ||
cp.status == CusProductStatus.PastDue) &&
!isOneOff(cp.customer_prices.map((cp: any) => cp.price))
);
let curMainProduct = cusProducts.find((cp: any) => {
let sameGroup = cp.product.group === product.group;
let isMain = !cp.product.is_add_on;
let isActive =
cp.status == CusProductStatus.Active ||
cp.status == CusProductStatus.PastDue;
let oneOff = isOneOff(cp.customer_prices.map((cp: any) => cp.price));
let sameEntity = internalEntityId
? cp.internal_entity_id === internalEntityId
: true;
return sameGroup && isMain && isActive && !oneOff && sameEntity;
});
const curSameProduct = cusProducts!.find(
(cp: any) => cp.product.internal_id === product.internal_id
(cp: any) =>
cp.product.internal_id === product.internal_id &&
(internalEntityId ? cp.internal_entity_id === internalEntityId : true)
);
const curScheduledProduct = cusProducts!.find(
(cp: any) =>
cp.status === CusProductStatus.Scheduled &&
cp.product.group === product.group &&
!cp.product.is_add_on
!cp.product.is_add_on &&
(internalEntityId ? cp.internal_entity_id === internalEntityId : true)
);
return { curMainProduct, curSameProduct, curScheduledProduct };
@@ -164,6 +175,7 @@ export const handleExistingProduct = async ({
await getExistingCusProducts({
product,
cusProducts: cusProducts || [],
internalEntityId: attachParams.internalEntityId,
});
if (isOneOff(product.prices)) {

View File

@@ -1,224 +0,0 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { pricesOnlyOneOff } from "@/internal/prices/priceUtils.js";
import { createFullCusProduct } from "./createFullCusProduct.js";
import { InvoiceService } from "../invoices/InvoiceService.js";
import {
AppEnv,
CollectionMethod,
CusProduct,
CusProductStatus,
Customer,
Organization,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { CusProductService } from "../products/CusProductService.js";
import Stripe from "stripe";
import {
getStripeSubItems,
pricesToInvoiceItems,
} from "@/external/stripe/stripePriceUtils.js";
import { AttachParams } from "../products/AttachParams.js";
import { attachToInsertParams } from "@/internal/products/productUtils.js";
import {
getInvoiceExpansion,
getStripeExpandedInvoice,
} from "@/external/stripe/stripeInvoiceUtils.js";
// export const removeCurrentProduct = async ({
// sb,
// customer,
// curCusProduct,
// org,
// env,
// }: {
// sb: SupabaseClient;
// customer: Customer;
// curCusProduct: CusProduct;
// org: Organization;
// env: AppEnv;
// }) => {
// console.log(" - Removing current product");
// // 1. Expire current product
// CusProductService.update({
// sb,
// cusProductId: curCusProduct.id,
// updates: {
// status: CusProductStatus.Expired,
// },
// });
// // Cancel stripe subscription
// const stripeCli = createStripeCli({ org, env: customer.env });
// for (const subId of curCusProduct.subscription_ids || []) {
// await stripeCli.subscriptions.cancel(subId, {
// prorate: true,
// });
// }
// };
// export const invoiceOnlyOneOff = async ({
// res,
// sb,
// attachParams,
// }: {
// res: any;
// sb: SupabaseClient;
// attachParams: any;
// }) => {
// // 1. Create stripe subscription (with invoice)
// const { org, env, customer, prices, product } = attachParams;
// const stripeCli = createStripeCli({ org, env });
// // Create invoice
// console.log(" - Creating stripe invoice");
// const invoice = await stripeCli.invoices.create({
// customer: customer.processor.id,
// collection_method: "send_invoice",
// days_until_due: 30,
// });
// // Add to invoice items
// await pricesToInvoiceItems({
// attachParams,
// stripeInvoiceId: invoice.id,
// sb,
// stripeCli,
// });
// // 2. Create full cus product
// console.log(" - Adding full cus product");
// await createFullCusProduct({
// sb,
// attachParams,
// lastInvoiceId: invoice.id,
// });
// // 3. Finalize invoice
// const finalizedInvoice = await stripeCli.invoices.finalizeInvoice(
// invoice.id,
// getInvoiceExpansion()
// );
// // 4. Create invoice from stripe
// await InvoiceService.createInvoiceFromStripe({
// sb,
// stripeInvoice: finalizedInvoice,
// internalCustomerId: customer.internal_id,
// org,
// productIds: [product.id],
// internalProductIds: [product.internal_id],
// });
// console.log(" ✅ Done");
// res.status(200).json({
// invoice_url: finalizedInvoice.hosted_invoice_url,
// });
// };
// export const handleInvoiceOnly = async ({
// req,
// res,
// attachParams,
// curCusProduct,
// }: {
// req: any;
// res: any;
// attachParams: AttachParams;
// curCusProduct: any;
// }) => {
// console.log("SCENARIO: INVOICE ONLY");
// const { org, customer, prices, products } = attachParams;
// // If current product, expire and cancel stripe subscription
// // if (curCusProduct && !product.is_add_on) {
// // // Handle removal of current product
// // }
// // Handle one off prices
// if (pricesOnlyOneOff(prices)) {
// console.log("Handling one-off priced product (invoice only)");
// await invoiceOnlyOneOff({
// sb: req.sb,
// attachParams,
// res,
// });
// return;
// }
// // 1. Create stripe subscription (with invoice)
// console.log(" - Creating stripe subscription");
// const stripeCli = createStripeCli({ org, env: customer.env });
// const itemSets = await getStripeSubItems({
// attachParams,
// });
// let stripeSubs: Stripe.Subscription[] = [];
// for (const itemSet of itemSets) {
// const { items, subMeta } = itemSet;
// // Create subscription
// const stripeSub = await stripeCli.subscriptions.create({
// customer: customer.processor.id,
// collection_method: "send_invoice",
// days_until_due: 30,
// items,
// metadata: subMeta,
// });
// stripeSubs.push(stripeSub);
// await stripeCli.subscriptions.update(stripeSub.id, {
// collection_method: "charge_automatically",
// });
// }
// // 1. Add full cus product
// console.log(" - Adding full cus product");
// for (const product of products) {
// await createFullCusProduct({
// sb: req.sb,
// attachParams: attachToInsertParams(attachParams, product),
// subscriptionId: stripeSubs[0].id,
// subscriptionIds: stripeSubs.map((s) => s.id),
// lastInvoiceId: stripeSubs[0].latest_invoice as string,
// });
// }
// let firstInvoice;
// for (const stripeSub of stripeSubs) {
// // Get stripe invoice
// console.log(" - Inserting stripe invoice into db");
// // 1. Finalize invoice
// await stripeCli.invoices.finalizeInvoice(
// stripeSub.latest_invoice as string
// );
// const stripeInvoice = await getStripeExpandedInvoice({
// stripeCli,
// stripeInvoiceId: stripeSub.latest_invoice as string,
// });
// if (!firstInvoice) {
// firstInvoice = stripeInvoice;
// }
// await InvoiceService.createInvoiceFromStripe({
// sb: req.sb,
// stripeInvoice,
// internalCustomerId: customer.internal_id,
// org,
// productIds: products.map((p) => p.id),
// internalProductIds: products.map((p) => p.internal_id),
// });
// }
// console.log(" ✅ Done");
// res.status(200).json({
// invoice_url: firstInvoice?.hosted_invoice_url,
// });
// };

View File

@@ -4,6 +4,7 @@ import {
ErrCode,
Feature,
FullCusProduct,
FullCustomer,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
@@ -31,6 +32,7 @@ import {
import { fullCusProductToProduct } from "../products/cusProductUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import { SuccessCode } from "@autumn/shared";
import { notNullish } from "@/utils/genUtils.js";
const getOptionsToUpdate = (oldOptionsList: any[], newOptionsList: any[]) => {
let differentOptionsExist = false;
@@ -297,7 +299,9 @@ export const handleSameMainProduct = async ({
}
}
if (optionsToUpdate.length === 0 && !curScheduledProduct) {
let isCanceled = notNullish(curMainProduct.canceled_at);
if (optionsToUpdate.length === 0 && !curScheduledProduct && !isCanceled) {
// Update options
throw new RecaseError({
message: `Customer already has product ${product.name}, can't attach again`,
@@ -308,13 +312,13 @@ export const handleSameMainProduct = async ({
let messages: string[] = [];
if (curScheduledProduct) {
// 1. Delete future product
const stripeCli = createStripeCli({
org,
env: customer.env,
});
// 1. Delete future product
const stripeCli = createStripeCli({
org,
env: customer.env,
});
if (curScheduledProduct) {
await cancelFutureProductSchedule({
sb,
org,
@@ -334,6 +338,25 @@ export const handleSameMainProduct = async ({
messages.push(
`Removed scheduled product ${curScheduledProduct.product.name}`
);
} else if (isCanceled) {
for (const subId of curMainProduct.subscription_ids || []) {
await stripeCli.subscriptions.update(subId, {
cancel_at: null,
});
}
let entities = attachParams.entities;
let entity = curMainProduct.internal_entity_id
? entities.find(
(e) => e.internal_id === curMainProduct.internal_entity_id
)
: undefined;
messages.push(
`Successfully renewed product ${product.name}${
entity ? ` for entity ${entity.name || entity.id}` : ""
}`
);
}
// 2. Update quantities
@@ -359,8 +382,8 @@ export const handleSameMainProduct = async ({
customer_id: customer.id,
product_ids: products.map((p) => p.id),
code: SuccessCode.PrepaidQuantityUpdated,
message: `Successfully updated prepaid quantities for ${products
code: SuccessCode.RenewedProduct,
message: `Successfully renewed product ${products
.map((p) => p.name)
.join(", ")}`,
})

View File

@@ -23,6 +23,7 @@ export const cancelScheduledProductIfExists = async ({
let { curScheduledProduct } = await getExistingCusProducts({
product: curFullProduct,
cusProducts: attachParams.cusProducts!,
internalEntityId: attachParams.internalEntityId,
});
if (curScheduledProduct) {

View File

@@ -137,6 +137,11 @@ export const handleDowngrade = async ({
// 1. Cancel all current subscriptions
const intervalToOtherSubs: Record<string, any> = {};
console.log(
"Subs: ",
curSubscriptions.map((s) => s.id)
);
for (const sub of curSubscriptions) {
let latestEndDate = new Date(latestPeriodEnd * 1000);
let curEndDate = new Date(sub.current_period_end * 1000);

View File

@@ -400,6 +400,7 @@ export const handleUpgrade = async ({
fromRequest: fromReq,
carryExistingUsages,
keepResetIntervals: newVersion, // keep reset intervals if upgrading version (migrations)
disableMerge: true,
});
if (notNullish(curCusProduct.subscription_ids)) {

View File

@@ -6,9 +6,12 @@ import {
AppEnv,
CusProductStatus,
CustomerData,
ErrCode,
FullCustomer,
Organization,
} from "@autumn/shared";
import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
export const getOrCreateCustomer = async ({
sb,
@@ -23,6 +26,8 @@ export const getOrCreateCustomer = async ({
CusProductStatus.Scheduled,
],
skipGet = false,
withEntities = false,
entityId,
}: {
sb: SupabaseClient;
org: Organization;
@@ -32,6 +37,8 @@ export const getOrCreateCustomer = async ({
logger: any;
inStatuses?: CusProductStatus[];
skipGet?: boolean;
withEntities?: boolean;
entityId?: string;
}): Promise<FullCustomer> => {
let customer;
@@ -42,6 +49,8 @@ export const getOrCreateCustomer = async ({
orgId: org.id,
env,
inStatuses,
withEntities,
entityId,
});
}
@@ -68,6 +77,8 @@ export const getOrCreateCustomer = async ({
orgId: org.id,
env,
inStatuses,
withEntities,
entityId,
});
} catch (error: any) {
if (error?.data?.code == "23505") {
@@ -77,6 +88,8 @@ export const getOrCreateCustomer = async ({
orgId: org.id,
env,
inStatuses,
withEntities,
entityId,
});
} else {
throw error;
@@ -91,5 +104,12 @@ export const getOrCreateCustomer = async ({
logger,
});
if (entityId && !customer.entity) {
throw new RecaseError({
message: `Entity ${entityId} not found for customer ${customerId}`,
code: ErrCode.EntityNotFound,
statusCode: StatusCodes.BAD_REQUEST,
});
}
return customer as FullCustomer;
};

View File

@@ -176,7 +176,9 @@ export const getCusEntBalance = ({
};
export const sortCusEntsForDeduction = (
cusEnts: FullCustomerEntitlement[],
cusEnts: (FullCustomerEntitlement & {
customer_product?: FullCusProduct;
})[],
reverseOrder: boolean = false
) => {
let intervalOrder: Record<EntInterval, number> = {
@@ -263,8 +265,7 @@ export const sortCusEntsForDeduction = (
}
// 3. Sort by interval
if (aEnt.interval && bEnt.interval) {
if (aEnt.interval && bEnt.interval && aEnt.interval != bEnt.interval) {
if (reverseOrder) {
return intervalOrder[bEnt.interval] - intervalOrder[aEnt.interval];
} else {
@@ -272,6 +273,18 @@ export const sortCusEntsForDeduction = (
}
}
// Check if a is main product
let aIsAddOn = a.customer_product?.product?.is_add_on;
let bIsAddOn = b.customer_product?.product?.is_add_on;
if (aIsAddOn && !bIsAddOn) {
return 1;
}
if (!aIsAddOn && bIsAddOn) {
return -1;
}
// 4. Sort by created_at
return a.created_at - b.created_at;
});
@@ -494,7 +507,7 @@ export const getTotalNegativeBalance = ({
.mul(billingUnits)
.toNumber();
}
totalNegative += entities[group].balance;
totalNegative += balance;
}
}
@@ -507,11 +520,13 @@ export const getExistingUsageFromCusProducts = ({
cusProducts,
entities,
carryExistingUsages = false,
internalEntityId,
}: {
entitlement: EntitlementWithFeature;
cusProducts?: FullCusProduct[];
entities: Entity[];
carryExistingUsages?: boolean;
internalEntityId?: string;
}) => {
if (!entitlement || entitlement.feature.type === FeatureType.Boolean) {
return 0;
@@ -535,7 +550,11 @@ export const getExistingUsageFromCusProducts = ({
// NOTE: Assuming that feature entitlements are unique to each main product...
let existingCusEnt = cusProducts
?.filter(
(cp) => cp.status === CusProductStatus.Active && !cp.product.is_add_on
(cp) =>
(cp.status === CusProductStatus.Active ||
cp.status === CusProductStatus.PastDue) &&
!cp.product.is_add_on &&
(internalEntityId ? cp.internal_entity_id === internalEntityId : true)
)
.flatMap((cp) => cp.customer_entitlements)
.find((ce) => ce.internal_feature_id === entitlement.internal_feature_id);

View File

@@ -42,6 +42,9 @@ export type AttachParams = {
isCustom?: boolean;
disableFreeTrial?: boolean;
features: Feature[];
entityId?: string;
internalEntityId?: string;
};
export type InsertCusProductParams = {
@@ -69,6 +72,9 @@ export type InsertCusProductParams = {
isCustom?: boolean;
disableFreeTrial?: boolean;
features: Feature[];
entityId?: string;
internalEntityId?: string;
};
export const AttachResultSchema = z.object({

View File

@@ -254,6 +254,7 @@ export class CusProductService {
env,
inStatuses,
withCusEnts = false,
withCusPrices = false,
}: {
sb: SupabaseClient;
stripeSubId: string;
@@ -261,6 +262,7 @@ export class CusProductService {
env: AppEnv;
inStatuses?: string[];
withCusEnts?: boolean;
withCusPrices?: boolean;
}) {
const query = sb
.from("customer_products")
@@ -269,6 +271,10 @@ export class CusProductService {
withCusEnts
? ", customer_entitlements:customer_entitlements(*, entitlement:entitlements!inner(*, feature:features!inner(*)))"
: ""
}${
withCusPrices
? ", customer_prices:customer_prices(*, price:prices!inner(*))"
: ""
}` as "*"
)
.or(

View File

@@ -6,6 +6,7 @@ import {
CustomerData,
Entitlement,
EntitlementWithFeature,
Entity,
Feature,
FeatureOptions,
FreeTrial,
@@ -20,9 +21,7 @@ import { SupabaseClient } from "@supabase/supabase-js";
import { ErrCode } from "@/errors/errCodes.js";
import RecaseError from "@/utils/errorUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { notNullish, nullish } from "@/utils/genUtils.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import {
getFreeTrialAfterFingerprint,
@@ -30,7 +29,6 @@ import {
} from "@/internal/products/free-trials/freeTrialUtils.js";
import { StatusCodes } from "http-status-codes";
import { CusService } from "../CusService.js";
import { getExistingCusProducts } from "../add-product/handleExistingProduct.js";
import { getPricesForCusProduct } from "../change-product/scheduleUtils.js";
import { EntityService } from "@/internal/api/entities/EntityService.js";
@@ -39,41 +37,6 @@ import { handleNewProductItems } from "@/internal/products/product-items/product
import { getBillingType } from "@/internal/prices/priceUtils.js";
import { Decimal } from "decimal.js";
const getOrCreateCustomerAndProducts = async ({
sb,
customerId,
customerData,
org,
env,
logger,
}: {
sb: SupabaseClient;
customerId: string;
customerData?: CustomerData;
org: Organization;
env: AppEnv;
logger: any;
}) => {
const customer = await getOrCreateCustomer({
sb,
org,
env,
customerId,
customerData,
logger,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.Scheduled,
CusProductStatus.PastDue,
],
});
// Handle existing cus product...
const cusProducts = customer.customer_products;
return { customer, cusProducts };
};
const getProducts = async ({
sb,
productId,
@@ -187,7 +150,7 @@ const getProducts = async ({
return [];
};
const getCustomerProductsFeaturesAndOrg = async ({
const getCustomerAndProducts = async ({
sb,
org,
customerId,
@@ -198,6 +161,7 @@ const getCustomerProductsFeaturesAndOrg = async ({
env,
logger,
version,
entityId,
}: {
sb: SupabaseClient;
org: Organization;
@@ -208,20 +172,30 @@ const getCustomerProductsFeaturesAndOrg = async ({
env: AppEnv;
logger: any;
version?: number;
entityId?: string;
}) => {
const [cusRes, products] = await Promise.all([
getOrCreateCustomerAndProducts({
const [customer, products] = await Promise.all([
getOrCreateCustomer({
sb,
customerId,
customerData,
org,
env,
customerId,
customerData,
logger,
inStatuses: [
CusProductStatus.Active,
CusProductStatus.Scheduled,
CusProductStatus.PastDue,
],
entityId,
withEntities: true,
}),
getProducts({ sb, productId, productIds, orgId: org.id, env, version }),
]);
return { ...cusRes, products };
let cusProducts = customer.customer_products;
return { customer, cusProducts, products };
};
const getEntsWithFeature = (ents: Entitlement[], features: Feature[]) => {
@@ -300,6 +274,7 @@ export const getFullCusProductData = async ({
customerId,
customerData,
productId,
entityId,
productIds,
orgId,
itemsInput,
@@ -315,6 +290,7 @@ export const getFullCusProductData = async ({
sb: SupabaseClient;
customerId: string;
customerData?: Customer;
entityId?: string;
productId?: string;
productIds?: string[];
orgId: string;
@@ -327,24 +303,17 @@ export const getFullCusProductData = async ({
version?: number;
}) => {
// 1. Get customer, product, org & features
const { customer, products, cusProducts } =
await getCustomerProductsFeaturesAndOrg({
org,
sb,
customerId,
customerData,
productId,
productIds,
env,
logger,
version,
});
const entities = await EntityService.get({
const { customer, products, cusProducts } = await getCustomerAndProducts({
org,
sb,
internalCustomerId: customer.internal_id,
orgId,
customerId,
customerData,
productId,
productIds,
env,
logger,
version,
entityId,
});
if (!isCustom) {
@@ -359,8 +328,6 @@ export const getFullCusProductData = async ({
});
}
return {
customer,
products,
@@ -377,8 +344,11 @@ export const getFullCusProductData = async ({
.flat() as EntitlementWithFeature[],
freeTrial,
cusProducts,
entities,
entities: customer.entities,
entityId: entityId,
internalEntityId: entityId
? customer.entities.find((e) => e.id === entityId)?.internal_id
: undefined,
};
}
@@ -454,6 +424,10 @@ export const getFullCusProductData = async ({
entitlements: entitlements as EntitlementWithFeature[],
freeTrial: uniqueFreeTrial,
cusProducts,
entities,
entities: customer.entities,
entityId: entityId,
internalEntityId: entityId
? customer.entities.find((e) => e.internal_id === entityId)?.internal_id
: undefined,
};
};

View File

@@ -23,6 +23,7 @@ import { createFullCusProduct } from "../add-product/createFullCusProduct.js";
import Stripe from "stripe";
import {
deleteScheduledIds,
getStripeSubs,
subIsPrematurelyCanceled,
} from "@/external/stripe/stripeSubUtils.js";
import { sortCusEntsForDeduction } from "../entitlements/cusEntUtils.js";
@@ -89,12 +90,14 @@ export const cancelCusProductSubscriptions = async ({
org,
env,
excludeIds,
expireImmediately = true,
}: {
sb: SupabaseClient;
cusProduct: FullCusProduct;
org: Organization;
env: AppEnv;
excludeIds?: string[];
expireImmediately?: boolean;
}) => {
// 1. Cancel all subscriptions
const stripeCli = createStripeCli({
@@ -102,13 +105,31 @@ export const cancelCusProductSubscriptions = async ({
env: env,
});
let latestSubEnd: number | undefined;
if (cusProduct.subscription_ids && cusProduct.subscription_ids.length > 0) {
let stripeSubs = await getStripeSubs({
stripeCli,
subIds: cusProduct.subscription_ids,
});
latestSubEnd = stripeSubs[0].current_period_end;
}
const cancelStripeSub = async (subId: string) => {
if (excludeIds && excludeIds.includes(subId)) {
return;
}
try {
await stripeCli.subscriptions.cancel(subId);
if (expireImmediately) {
await stripeCli.subscriptions.cancel(subId);
} else {
await stripeCli.subscriptions.update(subId, {
cancel_at: latestSubEnd || undefined,
cancel_at_period_end: latestSubEnd ? undefined : true,
});
}
console.log(
`Cancelled stripe subscription ${subId}, org: ${org.slug}, product: ${cusProduct.product.name}, customer: ${cusProduct.customer.id}`
);

View File

@@ -5,20 +5,27 @@ import { Organization } from "@autumn/shared";
export const getFeatureName = ({
feature,
plural,
capitalize = false,
}: {
feature: Feature;
plural: boolean;
capitalize: boolean;
}) => {
let featureName = feature.name;
if (!feature.display) {
return featureName;
if (feature.display) {
if (plural) {
featureName = feature.display.plural || featureName;
} else {
featureName = feature.display.singular || featureName;
}
}
if (plural) {
return feature.display.plural || featureName;
if (capitalize) {
featureName = featureName.charAt(0).toUpperCase() + featureName.slice(1);
}
return feature.display.singular || featureName;
return featureName;
};
export const getFeatureNameWithCapital = ({

View File

@@ -390,3 +390,21 @@ export const priceToAmountOrTiers = (price: Price) => {
}
}
};
export const roundUsage = ({
usage,
billingUnits,
}: {
usage: number;
billingUnits: number;
}) => {
if (!billingUnits || billingUnits == 1) {
return usage;
}
return new Decimal(usage)
.div(billingUnits)
.ceil()
.mul(billingUnits)
.toNumber();
};

View File

@@ -113,17 +113,20 @@ export const constructFeatureItem = ({
included_usage,
interval,
entitlement_id,
entity_feature_id,
}: {
feature_id: string;
included_usage?: number | string;
interval?: EntInterval;
entitlement_id?: string;
entity_feature_id?: string;
}) => {
let item: ProductItem = {
feature_id,
included_usage: included_usage as number,
interval: entToItemInterval(interval),
entitlement_id,
entity_feature_id,
};
return item;
@@ -146,6 +149,7 @@ export const constructPriceItem = ({
export const constructFeaturePriceItem = ({
feature_id,
feature_type,
included_usage,
price,
@@ -153,15 +157,17 @@ export const constructFeaturePriceItem = ({
usage_model,
billing_units = 1,
reset_usage_when_enabled = false,
entity_feature_id,
}: {
feature_id: string;
feature_type: ProductItemFeatureType;
feature_type?: ProductItemFeatureType;
included_usage?: number;
price: number;
interval: BillingInterval;
usage_model?: UsageModel;
billing_units?: number;
reset_usage_when_enabled?: boolean;
entity_feature_id?: string;
}) => {
let item: ProductItem & {
included_usage: number;
@@ -174,6 +180,7 @@ export const constructFeaturePriceItem = ({
usage_model,
billing_units,
reset_usage_when_enabled,
entity_feature_id,
};
return item;

View File

@@ -119,9 +119,15 @@ const initWorker = ({
return;
}
const { customerId } = job.data; // customerId is internal customer id
const { internalCustomerId } = job.data; // customerId is internal customer id
while (!(await acquireLock({ customerId, timeout: 10000, useBackup }))) {
while (
!(await acquireLock({
customerId: internalCustomerId,
timeout: 10000,
useBackup,
}))
) {
await queue.add(job.name, job.data, {
delay: 50,
});
@@ -141,7 +147,7 @@ const initWorker = ({
} catch (error) {
console.error("Error processing job:", error);
} finally {
await releaseLock({ customerId, useBackup });
await releaseLock({ customerId: internalCustomerId, useBackup });
}
},
{

View File

@@ -153,7 +153,9 @@ export const adjustAllowance = async ({
` - New quantity = ${paidUsage} (paid) + ${cusEnt.entitlement.allowance} (allowance) = ${quantity} `
);
let prorationBehaviour = "create_prorations";
let prorationBehaviour = org.config.bill_upgrade_immediately
? "always_invoice"
: "create_prorations";
// If prorate unused is false, then remove end of cycle
if (!org.config.prorate_unused) {
@@ -256,12 +258,22 @@ export const adjustAllowance = async ({
await stripeCli.subscriptionItems.update(subItem.id, {
quantity: quantity,
proration_behavior: prorationBehaviour as any,
payment_behavior: fromEntities ? "error_if_incomplete" : undefined,
});
logger.info(` ✅ Adjusted sub item ${subItem.id} to ${quantity}`);
} catch (error: any) {
logger.error(`❗️ Error updating subscription item`);
logger.error(error);
return;
if (fromEntities) {
throw new RecaseError({
message: `Failed to update subscription subscription: ${error.message}`,
code: ErrCode.StripeUpdateSubscriptionFailed,
statusCode: error.statusCode,
});
} else {
logger.error(
`❗️ adjustAllowance: Error updating subscription item (from event)`
);
logger.error(error);
}
}
return;

View File

@@ -5,6 +5,7 @@ import {
CusProductStatus,
Event,
Feature,
FullCustomer,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
@@ -31,6 +32,7 @@ import {
getTotalNegativeBalance,
} from "@/internal/customers/entitlements/cusEntUtils.js";
import { entityFeatureIdExists } from "@/internal/api/entities/entityUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
// Decimal.set({ precision: 12 }); // 12 DP precision
@@ -127,7 +129,6 @@ export const logBalanceUpdate = ({
entityId?: string | null;
org: Organization;
}) => {
console.log(` - getCusEntsInFeatures: ${timeTaken}ms`);
console.log(
` - Customer: ${customer.id} (${customer.env}) | Org: ${
org.slug
@@ -307,21 +308,12 @@ export const deductAllowanceFromCusEnt = async ({
entityId?: string | null;
setZeroAdjustment?: boolean;
}) => {
const { sb, feature, env, org, cusPrices, customer, properties } =
deductParams;
const { sb, feature, env, org, cusPrices, customer } = deductParams;
if (toDeduct == 0) {
return 0;
}
// Either deduct from balance or entity balance
// let newBalance = structuredClone(cusEnt.balance);
// let newEntities = structuredClone(cusEnt.entities);
// let deducted = 0;
// console.log("entityId", entityId);
let {
newBalance,
newEntities,
@@ -346,10 +338,6 @@ export const deductAllowanceFromCusEnt = async ({
balance: newBalance!,
entities: newEntities!,
});
// console.log("Saving:", {
// balance: newBalance,
// entities: newEntities,
// });
let updates: any = {
balance: newBalance,
@@ -358,6 +346,7 @@ export const deductAllowanceFromCusEnt = async ({
if (setZeroAdjustment) {
updates.adjustment = 0;
}
await CustomerEntitlementService.update({
sb,
id: cusEnt.id,
@@ -518,7 +507,8 @@ export const deductFromUsageBasedCusEnt = async ({
// Main function to update customer balance
export const updateCustomerBalance = async ({
sb,
customer,
customerId,
entityId,
event,
features,
org,
@@ -526,7 +516,8 @@ export const updateCustomerBalance = async ({
logger,
}: {
sb: SupabaseClient;
customer: Customer;
customerId: string;
entityId: string;
event: Event;
features: Feature[];
org: Organization;
@@ -535,13 +526,19 @@ export const updateCustomerBalance = async ({
}) => {
const startTime = performance.now();
console.log("REVERSE DEDUCTION ORDER", org.config.reverse_deduction_order);
const customer = await CusService.getWithProducts({
sb,
idOrInternalId: customerId,
orgId: org.id,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId,
});
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
sb,
internalCustomerId: customer.internal_id,
customer,
internalFeatureIds: features.map((f) => f.internal_id!),
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
withPrices: true,
logger,
reverseOrder: org.config.reverse_deduction_order,
});
@@ -566,14 +563,6 @@ export const updateCustomerBalance = async ({
entityId: event.entity_id,
});
// // 2. Handle group_by initialization
// await initGroupBalancesForEvent({
// sb,
// features,
// cusEnts,
// properties: event.properties,
// });
// 3. Return if no customer entitlements or features found
if (cusEnts.length === 0 || features.length === 0) {
console.log(" - No customer entitlements or features found");
@@ -643,49 +632,28 @@ export const runUpdateBalanceTask = async ({
}) => {
try {
// 1. Update customer balance
const { customer, features, event, org, env } = payload;
const { customerId, features, event, org, env, entityId } = payload;
console.log("--------------------------------");
console.log(
`UPDATING BALANCE FOR CUSTOMER (${customer.id}), ORG: ${org.slug}`
`UPDATING BALANCE FOR CUSTOMER (${customerId}), ORG: ${org.slug}`
);
const cusEnts: any = await updateCustomerBalance({
sb,
customer,
customerId,
features,
event,
org,
env,
logger,
entityId,
});
if (!cusEnts || cusEnts.length === 0) {
return;
}
console.log(" ✅ Customer balance updated");
// // 2. Check if there's below threshold price
// const belowThresholdPrice = await getBelowThresholdPrice({
// sb,
// internalCustomerId: customer.internal_id,
// cusEnts,
// });
// if (belowThresholdPrice) {
// console.log("2. Below threshold price found");
// // await new Promise((resolve) => setTimeout(resolve, 1000));
// await handleBelowThresholdInvoicing({
// sb,
// internalCustomerId: customer.internal_id,
// belowThresholdPrice,
// logger,
// });
// } else {
// console.log(" ✅ No below threshold price found");
// }
} catch (error) {
if (logger) {
logger.use((log: any) => {

View File

@@ -10,6 +10,7 @@ import {
Customer,
Feature,
FeatureType,
FullCustomer,
FullCustomerEntitlement,
Organization,
} from "@autumn/shared";
@@ -28,6 +29,7 @@ import {
deductFromUsageBasedCusEnt,
} from "./updateBalanceTask.js";
import { JobName } from "@/queue/JobName.js";
import { CusService } from "@/internal/customers/CusService.js";
// 2. Get deductions for each feature
const getFeatureDeductions = ({
@@ -114,25 +116,22 @@ const getFeatureDeductions = ({
};
const logUsageUpdate = ({
timeTaken,
customer,
features,
cusEnts,
featureDeductions,
properties,
org,
setUsage,
entityId,
}: {
timeTaken: string;
customer: Customer;
features: Feature[];
cusEnts: FullCustomerEntitlement[];
featureDeductions: any;
properties: any;
org: Organization;
setUsage: boolean;
entityId?: string;
}) => {
console.log(` - getCusEntsInFeatures: ${timeTaken}ms`);
console.log(
` - Customer: ${customer.id} (${customer.env}) | Org: ${
org.slug
@@ -145,12 +144,6 @@ const logUsageUpdate = ({
" - CusEnts:",
cusEnts.map((cusEnt: any) => {
let balanceStr = cusEnt.balance;
// let { groupVal, balance } = getGroupBalanceFromProperties({
// properties,
// cusEnt,
// features,
// });
try {
if (cusEnt.entitlement.allowance_type === AllowanceType.Unlimited) {
balanceStr = "Unlimited";
@@ -159,6 +152,10 @@ const logUsageUpdate = ({
balanceStr = "failed_to_get_balance";
}
if (entityId && cusEnt.entities) {
balanceStr = `${cusEnt.entities?.[entityId!]?.balance} [${entityId}]`;
}
return `${cusEnt.feature_id} - ${balanceStr} (${
cusEnt.customer_product ? cusEnt.customer_product.product_id : ""
})`;
@@ -171,7 +168,7 @@ const logUsageUpdate = ({
// Main function to update customer balance
export const updateUsage = async ({
sb,
customer,
customerId,
features,
org,
env,
@@ -182,7 +179,7 @@ export const updateUsage = async ({
entityId,
}: {
sb: SupabaseClient;
customer: Customer;
customerId: string;
features: Feature[];
org: Organization;
env: AppEnv;
@@ -190,19 +187,24 @@ export const updateUsage = async ({
properties: any;
setUsage: boolean;
logger: any;
entityId: string;
entityId?: string;
}) => {
const startTime = performance.now();
const customer = await CusService.getWithProducts({
sb,
idOrInternalId: customerId,
orgId: org.id,
env,
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
entityId,
});
const { cusEnts, cusPrices } = await getCusEntsInFeatures({
sb,
internalCustomerId: customer.internal_id,
customer,
internalFeatureIds: features.map((f) => f.internal_id!),
inStatuses: [CusProductStatus.Active, CusProductStatus.PastDue],
withPrices: true,
logger,
reverseOrder: org.config?.reverse_deduction_order,
});
const endTime = performance.now();
// 1. Get deductions for each feature
const featureDeductions = getFeatureDeductions({
@@ -213,32 +215,21 @@ export const updateUsage = async ({
});
logUsageUpdate({
timeTaken: (endTime - startTime).toFixed(2),
customer,
features,
cusEnts,
featureDeductions,
properties,
org,
setUsage,
entityId,
});
// // 2. Handle group_by initialization
// await initGroupBalancesForEvent({
// sb,
// features,
// cusEnts,
// properties,
// });
// 3. Return if no customer entitlements or features found
if (cusEnts.length === 0 || features.length === 0) {
console.log(" - No customer entitlements or features found");
return;
}
// 4. Perform deductions and update customer balance
for (const obj of featureDeductions) {
let { feature, deduction: toDeduct } = obj;
@@ -304,7 +295,8 @@ export const runUpdateUsageTask = async ({
try {
// 1. Update customer balance
const {
customer,
internalCustomerId,
customerId,
features,
value,
set_usage,
@@ -316,12 +308,12 @@ export const runUpdateUsageTask = async ({
console.log("--------------------------------");
console.log(
`HANDLING USAGE TASK FOR CUSTOMER (${customer.id}), ORG: ${org.slug}`
`HANDLING USAGE TASK FOR CUSTOMER (${customerId}), ORG: ${org.slug}`
);
const cusEnts: any = await updateUsage({
sb,
customer,
customerId,
features,
value,
properties,
@@ -336,26 +328,6 @@ export const runUpdateUsageTask = async ({
return;
}
console.log(" ✅ Customer balance updated");
// // 2. Check if there's below threshold price
// const belowThresholdPrice = await getBelowThresholdPrice({
// sb,
// internalCustomerId: customer.internal_id,
// cusEnts,
// });
// if (belowThresholdPrice) {
// console.log("2. Below threshold price found");
// await handleBelowThresholdInvoicing({
// sb,
// internalCustomerId: customer.internal_id,
// belowThresholdPrice,
// logger,
// });
// } else {
// console.log(" ✅ No below threshold price found");
// }
} catch (error) {
if (logger) {
logger.use((log: any) => {

View File

@@ -15,8 +15,8 @@ if [ "$1" == "basic-parallel" ]; then
elif [ "$1" == "advanced-parallel" ]; then
MOCHA_PARALLEL=true \
$MOCHA_SETUP \
&& $MOCHA_CMD 'tests/advanced/usage/*.ts' \
&& $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\
# && $MOCHA_CMD 'tests/advanced/usage/*.ts' \
# && $MOCHA_CMD 'tests/advanced/coupons/*.ts'\

View File

@@ -1,254 +0,0 @@
// import { assert, expect } from "chai";
// import { AutumnCli } from "tests/cli/AutumnCli.js";
// import { advanceProducts, features } from "tests/global.js";
// import { compareMainProduct } from "tests/utils/compare.js";
// import {
// advanceClockForInvoice,
// advanceTestClock,
// } from "../../utils/stripeUtils.js";
// import { timeout } from "../../utils/genUtils.js";
// import { createStripeCli } from "@/external/stripe/utils.js";
// import { addDays } from "date-fns";
// import chalk from "chalk";
// import Stripe from "stripe";
// import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
// const advanceArrearProratedCycle = async ({
// stripeSub,
// stripeCli,
// testClockId,
// customerId,
// billingUnits,
// startingFrom,
// startingBalance,
// }: {
// stripeSub: Stripe.Subscription;
// stripeCli: Stripe;
// testClockId: string;
// customerId: string;
// billingUnits: number;
// startingFrom?: number;
// startingBalance?: number;
// }) => {
// // 1. Get total period
// let totalPeriod =
// (stripeSub.current_period_end - stripeSub.current_period_start) * 1000;
// // 2. Get allowance
// let allowance =
// advanceProducts.proratedArrearSeats.entitlements.seats.allowance!;
// // 3. Get starting balance
// let balance = startingBalance || allowance;
// // 4. Get price per seat
// let pricePerSeat =
// advanceProducts.proratedArrearSeats.prices[1].config.usage_tiers[0].amount;
// let skipDays = 2;
// // 5. Get accrued price
// let accruedPrice = 0;
// if (startingBalance) {
// accruedPrice =
// (-startingBalance *
// pricePerSeat *
// (startingFrom! - stripeSub.current_period_start * 1000)) /
// totalPeriod;
// accruedPrice = Math.max(accruedPrice, 0);
// console.log(" 🔍 Starting balance: ", startingBalance);
// console.log(" 🔍 Starting price: ", accruedPrice);
// }
// let curTime = startingFrom || stripeSub.current_period_start * 1000;
// let numberOfEvents = 2;
// console.group();
// console.group();
// for (let i = 0; i < numberOfEvents; i++) {
// let sign = balance > 0 ? 1 : Math.random() > 0.5 ? 1 : -1;
// let currentUsage = allowance - balance;
// let nextBoundary =
// Math.ceil((currentUsage + 1) / billingUnits) * billingUnits;
// let prevBoundary = nextBoundary - billingUnits;
// let valueNeeded = 0;
// if (sign > 0) {
// // Add random amount to push above next boundary
// const valueToGetToNegative = balance + 1;
// valueNeeded =
// Math.floor(Math.random() * 10) + (nextBoundary - currentUsage + 1);
// valueNeeded = Math.max(valueNeeded, valueToGetToNegative);
// } else {
// valueNeeded = -(
// Math.floor(Math.random() * 10) +
// (currentUsage - prevBoundary + 1)
// );
// }
// let firstHalf = Math.floor(valueNeeded / 2);
// let secondHalf = valueNeeded - firstHalf;
// // Test event sending in quick succession
// await AutumnCli.sendEvent({
// customerId,
// eventName: features.seats.id,
// properties: {
// value: firstHalf,
// },
// });
// await AutumnCli.sendEvent({
// customerId,
// eventName: features.seats.id,
// properties: {
// value: secondHalf,
// },
// });
// await timeout(2000);
// let prevBalance = balance;
// balance -= valueNeeded;
// // Calculate prorated price only when crossing boundary
// let newPrice = Math.max(0, -balance * pricePerSeat);
// let prevCurTime = curTime;
// curTime = addDays(curTime, 2).getTime();
// if (i === numberOfEvents - 1) {
// curTime = stripeSub.current_period_end * 1000;
// }
// let proratedPrice = (newPrice * (curTime - prevCurTime)) / totalPeriod;
// accruedPrice += Number(proratedPrice.toFixed(2));
// console.log(`Event ${i + 1}:`);
// console.log(` - Value added: ${valueNeeded}`);
// console.log(` - Balance: ${prevBalance} -> ${balance}`);
// console.log(` - Prorated price: ${proratedPrice.toFixed(2)}`);
// console.log(` - Accrued price: ${accruedPrice.toFixed(2)}`);
// await advanceTestClock({
// stripeCli,
// testClockId,
// numberOfDays: 2,
// startingFrom: new Date(prevCurTime),
// });
// }
// console.groupEnd();
// console.groupEnd();
// // Advance test clock to end of period
// // let advanceTo = addDays(addMonths(new Date(), 1), 2);
// let advanceToStart = startingFrom ? new Date(startingFrom) : new Date();
// let advanceTo = await advanceClockForInvoice({
// stripeCli,
// testClockId,
// waitForMeterUpdate: false,
// // numberOfDays: 2,
// startingFrom: advanceToStart,
// });
// // Check invoice amount
// const res = await AutumnCli.getCustomer(customerId);
// let invoice = res.invoices[0];
// let basePrice = advanceProducts.proratedArrearSeats.prices[0].config.amount;
// let nextMonthUsagePrice = Math.max(-balance * pricePerSeat, 0);
// console.log(" 🔍 Next month usage price: ", nextMonthUsagePrice);
// let expectedTotal = Number(
// (accruedPrice + basePrice + nextMonthUsagePrice).toFixed(2)
// );
// expect(invoice.total).to.be.greaterThan(expectedTotal - 0.01);
// expect(invoice.total).to.be.lessThan(expectedTotal + 0.01);
// return {
// balance,
// advancedTo: advanceTo,
// };
// };
// describe(`${chalk.yellowBright(
// "Testing in_arrear_prorated -- update via events"
// )}`, () => {
// const customerId = "arrear-prorated-events";
// let testClockId = "";
// let stripeCli: Stripe;
// let subId = "";
// let stripeSub: Stripe.Subscription;
// let billingUnits =
// advanceProducts.proratedArrearSeats.prices[1].config.billing_units || 1;
// before(async function () {
// const { testClockId: createdTestClockId } = await initCustomerWithTestClock(
// {
// customerId,
// org: this.org,
// env: this.env,
// sb: this.sb,
// }
// );
// stripeCli = createStripeCli({
// org: this.org,
// env: this.env,
// });
// testClockId = createdTestClockId;
// });
// it("should attach in arrear prorated seats", async () => {
// await AutumnCli.attach({
// customerId,
// productId: advanceProducts.proratedArrearSeats.id,
// });
// });
// it("should have correct product", async () => {
// const res = await AutumnCli.getCustomer(customerId);
// compareMainProduct({
// sent: advanceProducts.proratedArrearSeats,
// cusRes: res,
// });
// // 2. Get subscription period start and period end
// subId = res.products[0].subscription_ids[0];
// stripeSub = await stripeCli.subscriptions.retrieve(subId);
// });
// it("should run two cycles and have correct invoice / balance", async () => {
// // Do it again
// let { advancedTo, balance } = await advanceArrearProratedCycle({
// stripeSub,
// stripeCli,
// testClockId,
// customerId,
// billingUnits,
// });
// // console.log(` Advanced to ${format(new Date(advancedTo), "yyyy-MM-dd")}`);
// // let newStripeSub = await stripeCli.subscriptions.retrieve(subId);
// // await advanceArrearProratedCycle({
// // stripeSub: newStripeSub,
// // stripeCli,
// // testClockId,
// // customerId,
// // billingUnits,
// // startingFrom: advancedTo,
// // startingBalance: balance,
// // });
// });
// // TODO: Test reset at for in arrear prorated with Ent Interval = Lifetime
// // TODO: Test in arrear prorated for entitlements with billing units > 1
// return;
// });

View File

@@ -11,6 +11,9 @@ import Stripe from "stripe";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import { checkSubscriptionContainsProducts } from "tests/utils/scheduleCheckUtils.js";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { CacheType } from "@/external/caching/cacheActions.js";
import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
const advanceAPThroughBalances = async ({
stripeSub,
@@ -169,7 +172,7 @@ const advanceAPThroughBalances = async ({
};
describe(`${chalk.yellowBright(
"Testing update in arrear prorated through /balances"
"arrear_prorated2: testing update in arrear prorated through /balances"
)}`, () => {
const customerId = "arrear-prorated-balances";
@@ -196,6 +199,23 @@ describe(`${chalk.yellowBright(
});
testClockId = createdTestClockId;
// Update org config
await this.sb
.from("organizations")
.update({
config: {
...this.org.config,
bill_upgrade_immediately: false,
},
})
.eq("id", this.org.id);
await CacheManager.invalidate({
action: CacheType.SecretKey,
value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
});
await CacheManager.disconnect();
});
it("should attach in arrear prorated seats", async () => {
@@ -258,6 +278,21 @@ describe(`${chalk.yellowBright(
});
});
after(async function () {
await this.sb
.from("organizations")
.update({
config: {
...this.org.config,
bill_upgrade_immediately: true,
},
})
.eq("id", this.org.id);
void CacheManager.invalidate({
action: CacheType.SecretKey,
value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
});
});
// TODO: Test reset at for in arrear prorated with Ent Interval = Lifetime
// TODO: Test in arrear prorated for entitlements with billing units > 1
});

View File

@@ -20,7 +20,8 @@ const checkEntitledOnProduct = async ({
}) => {
// 1. Send events
const allowance = totalAllowance || product.entitlements.metered1.allowance;
const randomNum = Math.floor(Math.random() * (allowance - 1));
// const randomNum = Math.floor(Math.random() * (allowance - 1));
const randomNum = 3;
const batchUpdates = [];
for (let i = 0; i < randomNum; i++) {

View File

@@ -0,0 +1,225 @@
import { compareMainProduct } from "../../utils/compare.js";
import { entityProducts, features } from "../../global.js";
import { assert, expect } from "chai";
import chalk from "chalk";
import { Autumn } from "@/external/autumn/autumnCli.js";
import { setupBefore } from "tests/before.js";
import {
BillingInterval,
CusProductStatus,
EntInterval,
ErrCode,
ProductItemFeatureType,
UsageModel,
} from "@autumn/shared";
import { getFeaturePrice, getUsagePriceTiers } from "tests/utils/genUtils.js";
import { Stripe } from "stripe";
import { CusService } from "@/internal/customers/CusService.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { checkBalance } from "tests/utils/autumnUtils.js";
import { initCustomerWithTestClock } from "tests/utils/testInitUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addHours, addMonths } from "date-fns";
import { CacheManager } from "@/external/caching/CacheManager.js";
import { CacheType } from "@/external/caching/cacheActions.js";
import { hashApiKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import {
constructFeatureItem,
constructFeaturePriceItem,
} from "@/internal/products/product-items/productItemUtils.js";
import { createProduct } from "tests/utils/productUtils.js";
// Check balance and stripe quantity
const checkEntAndStripeQuantity = async ({
sb,
autumn,
stripeCli,
featureId,
customerId,
expectedBalance,
expectedUsage,
expectedStripeQuantity,
}: {
sb: SupabaseClient;
autumn: Autumn;
stripeCli: Stripe;
featureId: string;
customerId: string;
expectedBalance: number;
expectedUsage?: number;
expectedStripeQuantity: number;
}) => {
let { customer, entitlements, products } = await autumn.customers.get(
customerId
);
let cusProducts = await CusService.getFullCusProducts({
sb,
internalCustomerId: customer.internal_id,
withPrices: true,
withProduct: true,
inStatuses: [CusProductStatus.Active],
});
let entitlement = entitlements.find((e: any) => e.feature_id == featureId);
expect(entitlement.balance).to.equal(expectedBalance);
if (expectedUsage) {
expect(entitlement.used).to.equal(
expectedUsage,
`Get customer ${customerId} returned incorrect "used" for feature ${featureId}`
);
}
if (products.length == 0) {
assert.fail(`Get customer ${customerId} returned no products`);
}
// 2. Get stripe quantity
let mainProduct = products[0];
if (mainProduct.subscription_ids.length == 0) {
assert.fail(`Get customer ${customerId} returned no subscriptions`);
}
let price = getFeaturePrice({
product: mainProduct,
featureId: featureId,
cusProducts,
});
if (!price) {
assert.fail(
`Get customer ${customerId} returned no price for feature ${featureId}`
);
}
let stripeSub = await stripeCli.subscriptions.retrieve(
mainProduct.subscription_ids[0]
);
let subItem = stripeSub.items.data.find(
(item: any) => item.price.id == price.config!.stripe_price_id
);
if (!subItem) {
assert.fail(
`Get customer ${customerId} returned no sub item for feature ${featureId}`
);
}
expect(subItem.quantity).to.equal(
expectedStripeQuantity,
`Get customer ${customerId} returned incorrect stripe quantity for feature ${featureId}`
);
};
// UNCOMMENT FROM HERE
let entity2Pro = {
id: "entity2Pro",
name: "Entity 2 Pro",
items: {
seats: constructFeaturePriceItem({
feature_id: features.seats.id,
included_usage: 0,
price: 150,
interval: BillingInterval.Month,
usage_model: UsageModel.PayPerUse,
}),
metered1: constructFeaturePriceItem({
feature_id: features.metered1.id,
included_usage: 50_000,
billing_units: 50_000,
price: 10,
interval: BillingInterval.Month,
entity_feature_id: features.seats.id,
}),
metered2: constructFeatureItem({
feature_id: features.metered2.id,
included_usage: 4000,
interval: EntInterval.Month,
entity_feature_id: features.seats.id,
}),
},
};
describe(`${chalk.yellowBright(
"entities2: Testing entities with prorate_unused: true"
)}`, () => {
let customerId = "entity2";
let autumn: Autumn;
let stripeCli: Stripe;
let testClockId: string;
before(async function () {
await setupBefore(this);
autumn = this.autumn;
stripeCli = this.stripeCli;
const { testClockId: testClockId1 } = await initCustomerWithTestClock({
customerId,
sb: this.sb,
org: this.org,
env: this.env,
});
await createProduct({
autumn: this.autumn,
product: entity2Pro,
});
testClockId = testClockId1;
// await this.sb
// .from("organizations")
// .update({
// config: {
// ...this.org.config,
// prorate_unused: true,
// },
// })
// .eq("id", this.org.id);
// await CacheManager.invalidate({
// action: CacheType.SecretKey,
// value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
// });
// await CacheManager.disconnect();
});
it("should create entity, then attach pro product", async function () {
await autumn.entities.create(customerId, {
id: "1",
name: "seat_1",
featureId: features.seats.id,
});
await autumn.attach({
customerId,
productId: entity2Pro.id,
});
let { customer, invoices } = await autumn.customers.get(customerId);
expect(invoices.length).to.equal(1);
expect(invoices[0].total).to.equal(entity2Pro.items.seats.price);
});
after(async function () {
// await this.sb
// .from("organizations")
// .update({
// config: {
// ...this.org.config,
// prorate_unused: true,
// },
// })
// .eq("id", this.org.id);
// void CacheManager.invalidate({
// action: CacheType.SecretKey,
// value: hashApiKey(process.env.UNIT_TEST_AUTUMN_SECRET_KEY!),
// });
});
});

View File

@@ -5,6 +5,7 @@ import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { AppEnv } from "@autumn/shared";
import { OrgService } from "@/internal/orgs/OrgService.js";
import { Autumn } from "@/external/autumn/autumnCli.js";
import { Autumn as AutumnJS } from "autumn-js";
import { createStripeCli } from "@/external/stripe/utils.js";
const ORG_SLUG = "unit-test-org";
@@ -12,14 +13,21 @@ const DEFAULT_ENV = AppEnv.Sandbox;
export const setupBefore = async (instance: any) => {
const sb = createSupabaseClient();
const org = await OrgService.getBySlug({sb, slug: ORG_SLUG});
const org = await OrgService.getBySlug({ sb, slug: ORG_SLUG });
const env = DEFAULT_ENV;
const autumnSecretKey = process.env.UNIT_TEST_AUTUMN_SECRET_KEY!;
const autumn = new Autumn(autumnSecretKey);
const stripeCli = createStripeCli({org, env});
const autumnJs = new AutumnJS({
secretKey: autumnSecretKey,
url: "http://localhost:8080/v1",
});
const stripeCli = createStripeCli({ org, env });
instance.sb = sb;
instance.org = org;
instance.env = env;
instance.autumn = autumn;
instance.stripeCli = stripeCli;
}
instance.autumnJs = autumnJs;
};

View File

@@ -16,6 +16,7 @@ export const createProduct = async ({
if (typeof clone.items === "object") {
clone.items = Object.values(clone.items);
}
await autumn.products.create(clone);
// await autumn.products.update(product.id, clone);

View File

@@ -20,4 +20,6 @@ export enum SuccessCode {
NewProductAttached = "new_product_attached",
OneOffProductAttached = "one_off_product_attached",
ProductFound = "product_found",
RenewedProduct = "renewed_product",
}

View File

@@ -45,10 +45,12 @@ export enum CusProductStatus {
export const CusProductSchema = z.object({
id: z.string(),
internal_customer_id: z.string(),
internal_product_id: z.string(),
customer_id: z.string().nullish(),
product_id: z.string(),
internal_customer_id: z.string(),
customer_id: z.string().nullish(),
internal_entity_id: z.string().nullish(),
entity_id: z.string().nullish(),
created_at: z.number(),
// Useful for event-driven subscriptions (and usage-based to check limits)

View File

@@ -1,6 +1,9 @@
import { Customer } from "./cusModels.js";
import { FullCusProduct } from "./cusProductModels.js";
import { Entity } from "./entityModels/entityModels.js";
export type FullCustomer = Customer & {
customer_products: FullCusProduct[];
entities: Entity[];
entity: Entity;
};

View File

@@ -13,6 +13,7 @@ export const OrgConfigSchema = z.object({
include_past_due: z.boolean().default(false),
sync_status: z.boolean().default(true),
merge_billing_cycles: z.boolean().default(true),
});
export type OrgConfig = z.infer<typeof OrgConfigSchema>;

View File

@@ -123,6 +123,10 @@ export const CustomerProductList = ({
key: "Stripe Subscription ID (1)",
value: cusProduct.subscription_ids?.[0] || "N/A",
},
{
key: "Entity ID",
value: cusProduct.entity_id || "N/A",
},
]}
>
<div className="flex items-center gap-2">

View File

@@ -21,6 +21,13 @@ import ErrorScreen from "@/views/general/ErrorScreen";
import { InvoicesTable } from "./InvoicesTable";
import { CustomerDetails } from "./CustomerDetails";
import { AdminHover } from "@/components/general/AdminHover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function CustomerView({ env }: { env: AppEnv }) {
const { customer_id } = useParams();
@@ -139,7 +146,18 @@ export default function CustomerView({ env }: { env: AppEnv }) {
)}
</h2>
</div>
{/* <CustomerToolbar customer={customer} /> */}
<Select>
<SelectTrigger>
<SelectValue placeholder="Select entity" />
</SelectTrigger>
<SelectContent>
{entities.map((entity: any) => (
<SelectItem key={entity.id} value={entity.id}>
{entity.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-full !pb-[50px]">
{/* main content */}

View File

@@ -61,6 +61,16 @@ export const CustomerEntitlementsList = () => {
return product?.name;
};
const getEntity = (cusEnt: FullCustomerEntitlement) => {
const cusProduct = customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id
);
const entity = entities.find(
(e: any) => e.internal_id === cusProduct?.internal_entity_id
);
return entity;
};
const sortedEntitlements = filteredEntitlements;
const handleSelectCusEntitlement = (cusEnt: FullCustomerEntitlement) => {
@@ -178,6 +188,7 @@ export const CustomerEntitlementsList = () => {
(cusEnt: FullCustomerEntitlement & { unused: number }) => {
const entitlement = cusEnt.entitlement;
const allowanceType = entitlement.allowance_type;
// const entity = getEntity(cusEnt);
return (
<Row
key={cusEnt.id}

View File

@@ -5,13 +5,7 @@ import { useEffect, useState } from "react";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
import { SelectTrigger, SelectValue } from "@/components/ui/select";
import { keyToTitle, slugify } from "@/utils/formatUtils/formatTextUtils";
import {
Reward,
CouponDurationType,
RewardType,
Product,
FullProduct,
} from "@autumn/shared";
import { Reward, RewardType, Product, FullProduct } from "@autumn/shared";
import { useProductsContext } from "../ProductsContext";
import { DiscountConfig } from "./DiscountConfig";
import { notNullish } from "@/utils/genUtils";