conflict resolution

This commit is contained in:
Ayush
2025-02-09 21:48:24 +00:00
parent 36288887d2
commit 632b625f9e
24 changed files with 819 additions and 165 deletions

View File

@@ -1,4 +1,10 @@
import { Feature } from "@autumn/shared";
import {
Entitlement,
Feature,
Price,
PriceType,
UsagePriceConfig,
} from "@autumn/shared";
export const getFeature = (
internalFeatureId: string | undefined,

View File

@@ -31,11 +31,13 @@ function CreateUsagePrice({
setConfig,
usageTiers,
setUsageTiers,
price,
}: {
config: any;
setConfig: (config: any) => void;
usageTiers: any[];
setUsageTiers: (usageTiers: any[]) => void;
price: Price;
}) {
const { features, product } = useProductContext();
@@ -65,6 +67,13 @@ function CreateUsagePrice({
const filteredEntitlements = product.entitlements.filter(
(entitlement: EntitlementWithFeature) => {
const config = price.config as UsagePriceConfig;
if (
config &&
config.internal_feature_id == entitlement.internal_feature_id
) {
return true;
}
if (
product.prices.some((price: Price) => {
const config = price.config as UsagePriceConfig;
@@ -93,6 +102,7 @@ function CreateUsagePrice({
?.internal_id,
});
}}
disabled={!!(price.config as UsagePriceConfig)?.internal_feature_id}
>
<SelectTrigger>
<SelectValue placeholder="Select entitlement" />

View File

@@ -93,6 +93,7 @@ export const PricingConfig = ({
setConfig={setUsageConfig}
usageTiers={usageTiers}
setUsageTiers={setUsageTiers}
price={price}
/>
</TabsContent>
</Tabs>

View File

@@ -35,13 +35,6 @@ const resetCustomerEntitlement = async ({
cusEnt: FullCustomerEntitlementWithProduct;
}) => {
try {
// console.log(`Resetting cusEnt ${cusEnt.id}`);
// console.log(
// `Customer: ${chalk.yellowBright(
// cusEnt.customer_id
// )}, Feature: ${chalk.yellowBright(cusEnt.entitlement.feature_id)}`
// );
// 1. Get allowance and quantity
const allowance = cusEnt.entitlement.allowance || 0;
@@ -54,12 +47,6 @@ const resetCustomerEntitlement = async ({
let quantity = (entOptions && entOptions.quantity) || 1;
const newBalance = allowance * quantity;
// console.log(
// `Allowance: ${chalk.yellow(allowance)} | Quantity: ${chalk.yellow(
// quantity
// )} | New Balance: ${chalk.yellow(newBalance)}`
// );
// 3. Update the next_reset_at for each entitlement
const nextResetAt = getNextResetAt(
new Date(cusEnt.next_reset_at!),

View File

@@ -0,0 +1,16 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const sendMeterEvent = async ({
sb,
customerId,
event,
}: {
sb: SupabaseClient;
customerId: string;
event: Event;
}) => {
const stripeCli = createStripeCli({
orgId: org.id,
env: org.env,
});
};

View File

@@ -23,6 +23,8 @@ export const createWebhookEndpoint = async (
"customer.subscription.deleted",
"checkout.session.completed",
"invoice.paid",
"invoice.created",
"invoice.finalized",
],
});

View File

@@ -8,11 +8,16 @@ import {
Price,
UsagePriceConfig,
FeatureOptions,
Entitlement,
Feature,
Product,
} from "@autumn/shared";
import { billingIntervalToStripe } from "./utils.js";
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
import Stripe from "stripe";
import { priceToStripeTiers } from "@/internal/prices/priceUtils.js";
export const priceToStripeItem = ({
price,
@@ -114,3 +119,39 @@ export const priceToStripeItem = ({
lineItemMeta,
};
};
export const createStripeMeteredPrice = async ({
stripeCli,
meterId,
product,
price,
entitlements,
feature,
}: {
stripeCli: Stripe;
meterId: string;
product: Product;
price: Price;
entitlements: Entitlement[];
feature: Feature;
}) => {
return await stripeCli.prices.create({
// product: product.processor!.id,
product_data: {
name: `${product.name} - ${feature!.name}`,
},
// unit_amount: ,
billing_scheme: "tiered",
tiers_mode: "volume",
tiers: priceToStripeTiers(
price,
entitlements.find((e) => e.internal_feature_id === feature!.internal_id)!
),
currency: "usd",
recurring: {
...(billingIntervalToStripe(price.config!.interval!) as any),
meter: meterId,
usage_type: "metered",
},
});
};

View File

@@ -9,7 +9,8 @@ import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
import { getStripeWebhookSecret } from "@/internal/orgs/orgUtils.js";
import { handleInvoicePaid } from "./webhookHandlers/handleInvoicePaid.js";
import chalk from "chalk";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { handleRequestError } from "@/utils/errorUtils.js";
import { handleInvoiceCreated } from "./webhookHandlers/handleInvoiceCreated.js";
export const stripeWebhookRouter = express.Router();
@@ -108,6 +109,17 @@ stripeWebhookRouter.post(
req: request,
});
break;
case "invoice.created":
const createdInvoice = event.data.object;
await handleInvoiceCreated({
sb: request.sb,
org,
invoice: createdInvoice,
env,
event,
});
break;
}
} catch (error) {
handleRequestError({

View File

@@ -0,0 +1,90 @@
import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js";
import { CusProductService } from "@/internal/customers/products/CusProductService.js";
import { getBillingType } from "@/internal/prices/priceUtils.js";
import {
AppEnv,
BillingType,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import Stripe from "stripe";
import { createStripeCli } from "../utils.js";
export const handleInvoiceCreated = async ({
sb,
org,
invoice,
env,
event,
}: {
sb: SupabaseClient;
org: Organization;
invoice: Stripe.Invoice;
env: AppEnv;
event: Stripe.Event;
}) => {
console.log("Invoice created: ", invoice.id);
// Get stripe subscriptions
if (invoice.subscription) {
const activeProducts = await CusProductService.getActiveByStripeSubId({
sb,
stripeSubId: invoice.subscription as string,
orgId: org.id,
env,
});
if (activeProducts.length != 1) {
console.log("Invalid number of active products: ", activeProducts.length);
return;
}
const activeProduct = activeProducts[0];
// 1. Remove invoiceItem from stripe
// Get cus ents
const cusProductWithEntsAndPrices =
await CusProductService.getEntsAndPrices({
sb,
cusProductId: activeProduct.id,
});
const cusEnts = cusProductWithEntsAndPrices.customer_entitlements;
const cusPrices = cusProductWithEntsAndPrices.customer_prices;
const cusUsagePrice = cusPrices.find(
(cusPrice: any) =>
getBillingType(cusPrice.price.config.type) === BillingType.UsageInArrear
);
if (cusUsagePrice) {
const config = cusUsagePrice.price.config as UsagePriceConfig;
console.log("Cus usage price config:", config);
// Remove price from stripe
// console.log("Cus usage price:", cusUsagePrice);
// Get invoice items
const stripeCli = createStripeCli({ org, env });
// console.log("Invoice items:", invoice.lines.data);
for (const item of invoice.lines.data) {
if (item.price?.id == config.stripe_price_id) {
// console.log("Removing invoice item:", item.id, item.price?.id);
}
// await stripeCli.invoiceItems.del(item.id);
await stripeCli.invoiceItems.update(item.id, {
// amount: 1000,
description: "Test update",
});
}
// // Add invoice item
// await stripeCli.invoiceItems.create({
// invoice: invoice.id,
// amount: 100,
// currency: org.default_currency,
// customer: invoice.customer as string,
// description: `Usage for ${config.stripe_price_id}`,
// });
}
}
};

View File

@@ -4,6 +4,7 @@ import {
CusProductStatus,
Customer,
CustomerResponseSchema,
FullCustomerPrice,
} from "@autumn/shared";
import RecaseError, { handleRequestError } from "@/utils/errorUtils.js";
import { ErrCode } from "@/errors/errCodes.js";
@@ -16,13 +17,15 @@ import { OrgService } from "@/internal/orgs/OrgService.js";
import { EventService } from "../events/EventService.js";
import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js";
import { FeatureService } from "@/internal/features/FeatureService.js";
import { createNewCustomer } from "./cusUtils.js";
import { createNewCustomer, getCusEntsAndPrices } from "./cusUtils.js";
import { CusProductService } from "@/internal/customers/products/CusProductService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import {
getCusBalancesByEntitlement,
getCusBalancesByProduct,
getRelatedCusPrice,
sortCusEntsForDeduction,
updateCusEntInStripe,
} from "@/internal/customers/entitlements/cusEntUtils.js";
import { processFullCusProduct } from "@/internal/customers/products/cusProductUtils.js";
import {
@@ -30,6 +33,8 @@ import {
processInvoice,
} from "@/internal/customers/invoices/InvoiceService.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { CusPriceService } from "@/internal/customers/prices/CusPriceService.js";
import { generateId } from "@/utils/genUtils.js";
export const cusRouter = Router();
@@ -305,6 +310,7 @@ cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
}
});
// Update customer entitlement directly
cusRouter.post(
"/customer_entitlements/:customer_entitlement_id",
async (req: any, res: any) => {
@@ -332,19 +338,52 @@ cusRouter.post(
}
// Check if org owns the entitlement
await CustomerEntitlementService.getByIdStrict({
const cusEnt = await CustomerEntitlementService.getByIdStrict({
sb: req.sb,
id: customer_entitlement_id,
orgId: req.orgId,
env: req.env,
});
const amountUsed = cusEnt.balance! - balance;
await CustomerEntitlementService.update({
sb: req.sb,
id: customer_entitlement_id,
updates: { balance, next_reset_at },
});
if (cusEnt.usage_allowed) {
// Get related usage price
const cusPrices: FullCustomerPrice[] =
await CusPriceService.getByCusProductId({
sb: req.sb,
customerProductId: cusEnt.customer_product_id,
});
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
if (!relatedCusPrice) {
res.status(200).json({ success: true });
return;
}
const fullOrg = await OrgService.getFullOrg({
sb: req.sb,
orgId: req.orgId,
});
await updateCusEntInStripe({
cusEnt,
cusPrices,
org: fullOrg,
env: req.env,
customer: cusEnt.customer,
amountUsed,
eventId: generateId("manual"),
});
}
res.status(200).json({ success: true });
} catch (error) {
handleRequestError({
@@ -369,6 +408,11 @@ cusRouter.post("/:customer_id/balances", async (req: any, res: any) => {
env: req.env,
});
const fullOrg = await OrgService.getFullOrg({
sb: req.sb,
orgId: req.orgId,
});
if (!customer) {
throw new RecaseError({
message: `Customer ${cusId} not found`,
@@ -382,14 +426,12 @@ cusRouter.post("/:customer_id/balances", async (req: any, res: any) => {
balances.map((b: any) => b.feature_id).includes(f.id)
);
const cusEnts = await CustomerEntitlementService.getActiveInFeatureIds({
const { cusEnts, cusPrices } = await getCusEntsAndPrices({
sb: req.sb,
internalCustomerId: customer.internal_id,
internalFeatureIds: featuresToUpdate.map((f) => f.internal_id),
});
sortCusEntsForDeduction(cusEnts);
// console.log("cusEnts", cusEnts);
for (const balance of balances) {
if (!balance.feature_id) {
@@ -416,21 +458,24 @@ cusRouter.post("/:customer_id/balances", async (req: any, res: any) => {
let newBalance = balance.balance;
for (const cusEnt of cusEnts) {
if (cusEnt.internal_feature_id === feature.internal_id) {
curBalance += cusEnt.balance;
curBalance += cusEnt.balance!;
}
}
let updateAmount = newBalance - curBalance;
let toDeduct = curBalance - newBalance;
for (const cusEnt of cusEnts) {
if (updateAmount == 0) break;
if (toDeduct == 0) break;
if (cusEnt.internal_feature_id === feature.internal_id) {
if (cusEnt.balance + updateAmount < 0) {
updateAmount += cusEnt.balance;
let amountUsed;
if (cusEnt.balance! - toDeduct < 0) {
toDeduct -= cusEnt.balance!;
amountUsed = cusEnt.balance!;
newBalance = 0;
} else {
newBalance = cusEnt.balance + updateAmount;
updateAmount = 0;
newBalance = cusEnt.balance! - toDeduct;
amountUsed = toDeduct;
toDeduct = 0;
}
await CustomerEntitlementService.update({
@@ -440,6 +485,21 @@ cusRouter.post("/:customer_id/balances", async (req: any, res: any) => {
balance: newBalance,
},
});
console.log("Amount used", amountUsed);
console.log("Feature", feature.name);
if (cusEnt.usage_allowed) {
await updateCusEntInStripe({
cusEnt,
cusPrices,
org: fullOrg,
env: req.env,
customer,
amountUsed,
eventId: generateId("manual"),
});
}
}
}
}

View File

@@ -3,8 +3,11 @@ import {
CusProductSchema,
Customer,
CustomerSchema,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
ProductSchema,
UsagePriceConfig,
} from "@autumn/shared";
import { CreateCustomer } from "@autumn/shared";
@@ -154,3 +157,44 @@ export const flipProductResults = (
}
return customers;
};
export const getCusEntsAndPrices = async ({
sb,
internalCustomerId,
internalFeatureIds,
}: {
sb: SupabaseClient;
internalCustomerId: string;
internalFeatureIds: string[];
}) => {
const cusWithProducts = await CusService.getActiveProductsByInternalId({
sb,
internalCustomerId,
});
if (!cusWithProducts) {
return { cusEnts: [], cusPrices: [] };
}
const cusProducts = cusWithProducts?.customer_products;
const cusEnts: FullCustomerEntitlement[] = [];
const cusPrices: FullCustomerPrice[] = [];
for (const cusProduct of cusProducts) {
cusEnts.push(
...cusProduct.customer_entitlements.filter(
(cusEnt: FullCustomerEntitlement) =>
internalFeatureIds.includes(cusEnt.entitlement.internal_feature_id!)
)
);
cusPrices.push(
...cusProduct.customer_prices.filter((cusPrice: FullCustomerPrice) => {
const priceConfig = cusPrice.price.config as UsagePriceConfig;
return internalFeatureIds.includes(priceConfig.internal_feature_id);
})
);
}
return { cusEnts, cusPrices };
};

View File

@@ -15,6 +15,7 @@ import { Client } from "pg";
import { Queue } from "bullmq";
import { createNewCustomer } from "../customers/cusUtils.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { OrgService } from "@/internal/orgs/OrgService.js";
export const eventsRouter = Router();
@@ -121,6 +122,11 @@ export const handleEventSent = async ({
}) => {
const { sb, pg, orgId, env } = req;
const org = await OrgService.getFullOrg({
sb,
orgId,
});
const { customer, event } = await getEventAndCustomer({
sb,
orgId,
@@ -144,6 +150,8 @@ export const handleEventSent = async ({
customer,
features: affectedFeatures,
event,
org,
env,
});
}
};

View File

@@ -532,4 +532,43 @@ export class CusService {
}
// ENTITLEMENTS
// Get active products
static async getActiveProductsByInternalId({
sb,
internalCustomerId,
}: {
sb: SupabaseClient;
internalCustomerId: string;
}) {
const { data, error } = await sb
.from("customers")
.select(
`
*,
customer_products:customer_products!inner(*,
customer_prices:customer_prices!inner(*,
price:prices!inner(*)
),
customer_entitlements:customer_entitlements!inner(*,
entitlement:entitlements!inner(*,
feature:features!inner(*)
)
)
)
`
)
.eq("internal_id", internalCustomerId)
.eq("customer_products.status", CusProductStatus.Active)
.single();
if (error) {
if (error.code === "PGRST116") {
return null;
}
throw error;
}
return data;
}
}

View File

@@ -8,6 +8,7 @@ import {
CusProduct,
FeatureOptions,
FreeTrial,
BillingType,
} from "@autumn/shared";
import { generateId } from "@/utils/genUtils.js";
import { getNextEntitlementReset } from "@/utils/timeUtils.js";
@@ -17,12 +18,15 @@ import { SupabaseClient } from "@supabase/supabase-js";
import { ErrCode } from "@/errors/errCodes.js";
import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
import { getEntOptions } from "@/internal/prices/priceUtils.js";
import { getBillingType, getEntOptions } from "@/internal/prices/priceUtils.js";
import { CustomerPrice } from "@autumn/shared";
import { CusProductService } from "../products/CusProductService.js";
import { AttachParams } from "../products/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { applyTrialToEntitlement } from "@/internal/products/entitlements/entitlementUtils.js";
import {
applyTrialToEntitlement,
getEntRelatedPrice,
} from "@/internal/products/entitlements/entitlementUtils.js";
export const initCusEntitlement = ({
entitlement,
@@ -32,6 +36,7 @@ export const initCusEntitlement = ({
options,
nextResetAt,
billLaterOnly = false,
relatedPrice,
}: {
entitlement: EntitlementWithFeature;
customer: Customer;
@@ -40,6 +45,7 @@ export const initCusEntitlement = ({
options?: FeatureOptions;
nextResetAt?: number;
billLaterOnly?: boolean;
relatedPrice?: Price;
}) => {
const feature: Feature = entitlement.feature;
@@ -77,6 +83,14 @@ export const initCusEntitlement = ({
entitlement.allowance_type === AllowanceType.Unlimited ||
entitlement.interval == EntInterval.Lifetime;
let usageAllowed = false;
if (
relatedPrice &&
getBillingType(relatedPrice.config!) === BillingType.UsageInArrear
) {
usageAllowed = true;
}
return {
id: generateId("cus_ent"),
internal_customer_id: customer.internal_id,
@@ -94,7 +108,7 @@ export const initCusEntitlement = ({
? null
: entitlement.allowance_type === AllowanceType.Unlimited,
balance: isBooleanFeature ? null : balance,
usage_allowed: isBooleanFeature ? null : false,
usage_allowed: usageAllowed,
next_reset_at: nextResetNull ? null : nextResetAt || nextResetAtCalculated,
};
};
@@ -309,6 +323,7 @@ export const createFullCusProduct = async ({
for (const entitlement of entitlements) {
const options = getEntOptions(optionsList, entitlement);
const relatedPrice = getEntRelatedPrice(entitlement, prices);
const cusEnt: any = initCusEntitlement({
entitlement,
@@ -318,6 +333,7 @@ export const createFullCusProduct = async ({
nextResetAt,
billLaterOnly,
freeTrial: disableFreeTrial ? null : freeTrial,
relatedPrice,
});
cusEnts.push(cusEnt);

View File

@@ -1,5 +1,5 @@
import RecaseError from "@/utils/errorUtils.js";
import { CustomerEntitlement, ErrCode } from "@autumn/shared";
import { CusProductStatus, CustomerEntitlement, ErrCode } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { StatusCodes } from "http-status-codes";
@@ -178,7 +178,6 @@ export class CustomerEntitlementService {
orgId: string;
env: string;
}) {
console.log("Getting entitlements for customer: ", customerId);
const { data, error } = await sb
.from("customer_entitlements")
.select(
@@ -336,7 +335,9 @@ export class CustomerEntitlementService {
}) {
const { data, error } = await sb
.from("customer_entitlements")
.select("*, customer:customers!inner(*)")
.select(
"*, customer:customers!inner(*), entitlement:entitlements!inner(*)"
)
.eq("id", id)
.eq("customer.org_id", orgId)
.eq("customer.env", env)
@@ -355,4 +356,23 @@ export class CustomerEntitlementService {
return data;
}
static async getByCusProductId({
sb,
cusProductId,
}: {
sb: SupabaseClient;
cusProductId: string;
}) {
const { data, error } = await sb
.from("customer_entitlements")
.select("*, entitlement:entitlements!inner(*)")
.eq("customer_product_id", cusProductId);
if (error) {
throw error;
}
return data;
}
}

View File

@@ -3,14 +3,18 @@ import { CustomerEntitlementService } from "./CusEntitlementService.js";
import { SupabaseClient } from "@supabase/supabase-js";
import {
AllowanceType,
BillingInterval,
CustomerEntitlement,
AppEnv,
Customer,
EntInterval,
EntitlementWithFeature,
FeatureType,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { getEntOptions } from "@/internal/prices/priceUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
export const getFeatureBalance = async ({
pg,
@@ -373,3 +377,61 @@ export const sortCusEntsForDeduction = (cusEnts: FullCustomerEntitlement[]) => {
return a.created_at - b.created_at;
});
};
// Get related cusPrice
export const getRelatedCusPrice = (
cusEnt: FullCustomerEntitlement,
cusPrices: FullCustomerPrice[]
) => {
return cusPrices.find((cusPrice) => {
if (cusPrice.customer_product_id == cusEnt.customer_product_id) {
let config = cusPrice.price.config as UsagePriceConfig;
return (
config.internal_feature_id == cusEnt.entitlement.internal_feature_id
);
}
return false;
});
};
// 3. Perform deductions and update customer balance
export const updateCusEntInStripe = async ({
cusEnt,
cusPrices,
org,
env,
customer,
amountUsed,
eventId,
}: {
cusEnt: FullCustomerEntitlement;
cusPrices: FullCustomerPrice[];
org: Organization;
env: AppEnv;
customer: Customer;
amountUsed: number;
eventId: string;
}) => {
const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
if (!relatedCusPrice) {
return;
}
// Send event to Stripe
const stripeCli = createStripeCli({
org,
env,
});
await stripeCli.billing.meterEvents.create({
event_name: relatedCusPrice.price.id!,
payload: {
stripe_customer_id: customer.processor.id,
value: amountUsed.toString(),
},
identifier: eventId,
});
console.log(` ✅ Stripe event sent, amount: (${amountUsed})`);
};

View File

@@ -0,0 +1,30 @@
import RecaseError from "@/utils/errorUtils.js";
import { ErrCode } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { StatusCodes } from "http-status-codes";
export class CusPriceService {
static async getByCusProductId({
sb,
customerProductId,
}: {
sb: SupabaseClient;
customerProductId: string;
}) {
const { data, error } = await sb
.from("customer_prices")
.select("*, price:prices(*)")
.eq("customer_product_id", customerProductId);
if (error) {
throw new RecaseError({
message: "Error getting customer prices",
code: ErrCode.GetCusPriceFailed,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
data: error,
});
}
return data;
}
}

View File

@@ -236,6 +236,32 @@ export class CusProductService {
return data;
}
static async getEntsAndPrices({
sb,
cusProductId,
}: {
sb: SupabaseClient;
cusProductId: string;
}) {
const { data, error } = await sb
.from("customer_products")
.select(
`
*,
customer_entitlements:customer_entitlements!inner(*, entitlement:entitlements!inner(*)),
customer_prices:customer_prices!inner(*, price:prices!inner(*))
`
)
.eq("id", cusProductId)
.single();
if (error) {
throw error;
}
return data;
}
static async getPastDueByInvoiceId({
sb,
invoiceId,

View File

@@ -17,12 +17,17 @@ import {
UsagePriceConfigSchema,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { getBillingType, priceToStripeTiers } from "./priceUtils.js";
import {
getBillingType,
priceToStripeTiers,
roundPriceAmounts,
} from "./priceUtils.js";
import { PriceService } from "./PriceService.js";
import {
billingIntervalToStripe,
createStripeCli,
} from "@/external/stripe/utils.js";
import { createStripeMeteredPrice } from "@/external/stripe/stripePriceUtils.js";
// GET PRICES
const validatePrice = (price: Price) => {
@@ -155,48 +160,61 @@ const handleStripePrices = async ({
for (const price of prices) {
const config = price.config! as UsagePriceConfig;
const billingType = getBillingType(config);
// If price.config.meter_id and stripe_price_id, delete
if (billingType == BillingType.UsageInArrear) {
const feature = features.find(
(f) => f.internal_id === config.internal_feature_id
);
if (!config.stripe_price_id) {
const feature = features.find(
(f) => f.internal_id === config.internal_feature_id
);
const meter = await stripeCli.billing.meters.create({
display_name: `${product.name} - ${feature!.name}`,
event_name: price.id!,
default_aggregation: {
formula: "sum",
},
});
const meter = await stripeCli.billing.meters.create({
display_name: `${product.name} - ${feature!.name}`,
event_name: price.id!,
default_aggregation: {
formula: "sum",
},
});
const stripePrice = await stripeCli.prices.create({
// product: product.processor!.id,
product_data: {
name: `${product.name} - ${feature!.name}`,
},
// unit_amount: ,
billing_scheme: "tiered",
tiers_mode: "volume",
tiers: priceToStripeTiers(
const stripePrice = await createStripeMeteredPrice({
stripeCli,
product,
price,
entitlements.find(
(e) => e.internal_feature_id === feature!.internal_id
)!
),
currency: "usd",
recurring: {
...(billingIntervalToStripe(config.interval!) as any),
meter: meter.id,
usage_type: "metered",
},
});
entitlements,
feature: feature!,
meterId: meter.id,
});
let newUsageConfig = {
...config,
stripe_meter_id: meter.id,
stripe_price_id: stripePrice.id,
};
let newUsageConfig = {
...config,
stripe_meter_id: meter.id,
stripe_price_id: stripePrice.id,
};
price.config = newUsageConfig;
price.config = newUsageConfig;
} else {
// Update price
// Set old price to inactive
await stripeCli.prices.update(config.stripe_price_id, {
active: false,
});
const feature = features.find(
(f) => f.internal_id === config.internal_feature_id
);
const stripePrice = await createStripeMeteredPrice({
stripeCli,
product,
price,
entitlements,
feature: feature!,
meterId: config.stripe_meter_id!,
});
config.stripe_price_id = stripePrice.id;
}
}
}
};
@@ -243,6 +261,7 @@ export const handleNewPrices = async ({
for (let newPrice of newPrices) {
// Validate price
validatePrice(newPrice);
roundPriceAmounts(newPrice);
// 1. Handle new price
if (!("id" in newPrice)) {

View File

@@ -271,7 +271,7 @@ export const getPriceAmount = (price: Price, options: FeatureOptions) => {
if (price.billing_type == BillingType.OneOff) {
let config = price.config as FixedPriceConfig;
return {
amountPerUnit: config.amount,
amountPerUnit: Number(config.amount.toFixed(2)),
quantity: 1,
};
} else if (price.billing_type == BillingType.UsageInAdvance) {
@@ -279,7 +279,7 @@ export const getPriceAmount = (price: Price, options: FeatureOptions) => {
let usageTier = getUsageTier(price, quantity);
return {
amountPerUnit: usageTier.amount,
amountPerUnit: Number(usageTier.amount.toFixed(2)),
quantity: quantity,
};
}
@@ -314,9 +314,28 @@ export const priceToStripeTiers = (price: Price, entitlement: Entitlement) => {
up_to: tier.to == -1 ? "inf" : tier.to,
});
}
console.log("Tiers: ", tiers);
return tiers;
};
export const priceToEventName = (productName: string, featureName: string) => {
return `${productName} - ${featureName}`;
};
export const roundPriceAmounts = (price: Price) => {
if (price.config!.type == PriceType.Fixed) {
const config = price.config as FixedPriceConfig;
config.amount = Number(config.amount.toFixed(2));
price.config = config;
} else if (price.config!.type == PriceType.Usage) {
const config = price.config as UsagePriceConfig;
for (let i = 0; i < config.usage_tiers.length; i++) {
config.usage_tiers[i].amount = Number(
config.usage_tiers[i].amount.toFixed(2)
);
}
price.config = config;
}
};

View File

@@ -12,6 +12,9 @@ import {
Feature,
ErrCode,
EntitlementSchema,
UsagePriceConfig,
PriceType,
Price,
} from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { addDays } from "date-fns";
@@ -258,3 +261,18 @@ export const handleNewEntitlements = async ({
`Successfully handled new entitlements. Created ${createdEnts.length}, updated ${updatedEnts.length}, removed ${removedEnts.length}`
);
};
// OTHERS
export const getEntRelatedPrice = (
entitlement: Entitlement,
prices: Price[]
) => {
return prices.find((price) => {
if (price.config?.type === PriceType.Fixed) {
return false;
}
const config = price.config as UsagePriceConfig;
return config.internal_feature_id === entitlement.internal_feature_id;
});
};

View File

@@ -70,6 +70,8 @@ const initWorker = (id: number, queue: Queue) => {
try {
await runUpdateBalanceTask(job.data);
} catch (error) {
console.error("Error updating balance:", error);
} finally {
await releaseLock(customerId);
}

View File

@@ -2,13 +2,31 @@ import { createSupabaseClient } from "@/external/supabaseUtils.js";
import { handleBelowThresholdInvoicing } from "./invoiceThresholdUtils.js";
import { getBelowThresholdPrice } from "./invoiceThresholdUtils.js";
import { AggregateType, AllowanceType, Event, Feature } from "@autumn/shared";
import {
AggregateType,
AllowanceType,
AppEnv,
CusEntWithEntitlement,
Event,
Feature,
FullCustomerEntitlement,
FullCustomerPrice,
Organization,
UsagePriceConfig,
} from "@autumn/shared";
import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js";
import { Customer, FeatureType } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { SbChannelEvent } from "@/websockets/initWs.js";
import chalk from "chalk";
import { sortCusEntsForDeduction } from "@/internal/customers/entitlements/cusEntUtils.js";
import {
getRelatedCusPrice,
sortCusEntsForDeduction,
updateCusEntInStripe,
} from "@/internal/customers/entitlements/cusEntUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { CusService } from "@/internal/customers/CusService.js";
import { getCusEntsAndPrices } from "@/internal/api/customers/cusUtils.js";
// 3. Get customer entitlements and sort
const getCustomerEntitlements = async ({
@@ -88,135 +106,216 @@ const getCreditSystemDeduction = ({
return creditsUpdate;
};
// 1. Main function to update customer balance
// // 1. Get customer entitlements and prices
// const getCustomerEntitlementsAndPrices = async ({
// sb,
// internalCustomerId,
// features,
// }: {
// sb: SupabaseClient;
// internalCustomerId: string;
// features: Feature[];
// }) => {
// const internalFeatureIds = features.map((feature) => feature.internal_id);
// const cusWithProducts = await CusService.getActiveProductsByInternalId({
// sb,
// internalCustomerId,
// });
// if (!cusWithProducts) {
// return { cusEnts: [], cusPrices: [] };
// }
// const cusProducts = cusWithProducts?.customer_products;
// const cusEnts: FullCustomerEntitlement[] = [];
// const cusPrices: FullCustomerPrice[] = [];
// for (const cusProduct of cusProducts) {
// cusEnts.push(
// ...cusProduct.customer_entitlements.filter(
// (cusEnt: FullCustomerEntitlement) =>
// internalFeatureIds.includes(cusEnt.entitlement.internal_feature_id)
// )
// );
// cusPrices.push(
// ...cusProduct.customer_prices.filter((cusPrice: FullCustomerPrice) => {
// const priceConfig = cusPrice.price.config as UsagePriceConfig;
// return internalFeatureIds.includes(priceConfig.internal_feature_id);
// })
// );
// }
// sortCusEntsForDeduction(cusEnts);
// return { cusEnts, cusPrices };
// };
// 2. Get deductions for each feature
const getFeatureDeductions = ({
cusEnts,
event,
features,
}: {
cusEnts: FullCustomerEntitlement[];
event: Event;
features: Feature[];
}) => {
const meteredFeatures = features.filter(
(feature) => feature.type === FeatureType.Metered
);
const featureDeductions = [];
for (const feature of features) {
let deduction;
if (feature.type === FeatureType.Metered) {
deduction = getMeteredDeduction(feature, event);
} else if (feature.type === FeatureType.CreditSystem) {
deduction = getCreditSystemDeduction({
meteredFeatures: meteredFeatures,
creditSystem: feature,
event,
});
}
// Check if unlimited exists
let unlimitedExists = cusEnts.some(
(cusEnt) => cusEnt.entitlement.allowance_type === AllowanceType.Unlimited
);
if (unlimitedExists || !deduction) {
continue;
}
featureDeductions.push({
feature,
deduction,
});
}
return featureDeductions;
};
// // 3. Perform deductions and update customer balance
// const handleUsageAllowedCusEnt = async ({
// cusEnt,
// cusPrices,
// org,
// env,
// customer,
// amountUsed,
// }: {
// cusEnt: FullCustomerEntitlement;
// cusPrices: FullCustomerPrice[];
// org: Organization;
// env: AppEnv;
// customer: Customer;
// amountUsed: number;
// }) => {
// const relatedCusPrice = getRelatedCusPrice(cusEnt, cusPrices);
// if (!relatedCusPrice) {
// return;
// }
// // Send event to Stripe
// const stripeCli = createStripeCli({
// org,
// env,
// });
// await stripeCli.billing.meterEvents.create({
// event_name: relatedCusPrice.price.id!,
// payload: {
// stripe_customer_id: customer.processor.id,
// value: amountUsed.toString(),
// },
// });
// console.log(" ✅ Stripe event sent");
// };
// Main function to update customer balance
export const updateCustomerBalance = async ({
sb,
customer,
event,
features,
org,
env,
}: {
sb: SupabaseClient;
customer: Customer;
event: Event;
features: Feature[];
org: Organization;
env: AppEnv;
}) => {
const cusEnts = await getCustomerEntitlements({
const startTime = performance.now();
const { cusEnts, cusPrices } = await getCusEntsAndPrices({
sb,
internalCustomerId: customer.internal_id,
features,
internalFeatureIds: features.map((f) => f.internal_id!),
});
const endTime = performance.now();
console.log(` - Cus ents func ${(endTime - startTime).toFixed(2)}ms`);
if (cusEnts.length === 0 || features.length === 0) {
return;
}
const channel = sb.channel(
`${customer.org_id}_${customer.env}_${customer.id}`
);
// Update customer balance
const featureIdToDeduction: any = {};
const meteredFeatures = features.filter(
(feature) => feature.type === FeatureType.Metered
);
console.log(` - Customer: ${customer.name} (${customer.internal_id})`);
console.log(` - Features: ${features.map((f) => f.id).join(", ")}`);
for (const cusEnt of cusEnts) {
const internalFeatureId = cusEnt.internal_feature_id;
if (featureIdToDeduction[internalFeatureId]) {
continue;
}
const feature = cusEnt.entitlement.feature;
// 1. Skip if customer has unlimited entitlement
let unlimitedExists = false;
for (const cusEnt of cusEnts) {
if (cusEnt.entitlement.allowance_type == AllowanceType.Unlimited) {
unlimitedExists = true;
break;
}
}
if (unlimitedExists) {
continue;
}
// 2. Get metered feature deduction
if (feature.type === FeatureType.Metered) {
let deduction = getMeteredDeduction(feature, event);
featureIdToDeduction[internalFeatureId] = {
cusEntId: cusEnt.id,
deduction,
feature: feature,
};
}
// 3. Get credit system deduction
if (feature.type === FeatureType.CreditSystem) {
const deduction = getCreditSystemDeduction({
meteredFeatures,
creditSystem: feature,
event,
});
featureIdToDeduction[internalFeatureId] = {
cusEntId: cusEnt.id,
deduction: deduction,
feature: feature,
};
}
let deduction = featureIdToDeduction[internalFeatureId]?.deduction;
let curBalance = cusEnt.balance!;
if (curBalance === undefined || curBalance === null || !deduction) {
continue;
}
}
// Feature ID to Deduction
const deductions: {
deduction: number;
feature: Feature;
}[] = Object.values(featureIdToDeduction);
const featureDeductions = getFeatureDeductions({
cusEnts,
event,
features,
});
for (const obj of deductions) {
console.log(
" - Deductions:",
featureDeductions.map((f) => `${f.feature.id}: ${f.deduction}`)
);
// 3. Perform deductions and update customer balance
for (const obj of featureDeductions) {
if (!obj.deduction) {
continue;
}
let toDeduct = obj.deduction;
// 1. Deduct from entitlement (till 0)
for (const cusEnt of cusEnts) {
if (cusEnt.internal_feature_id === obj.feature.internal_id) {
// If deduction finished or cusent has no more balance, break
if (toDeduct == 0) {
break;
}
if (cusEnt.balance == 0) {
if (cusEnt.balance! <= 0) {
continue;
}
let newBalance;
let newBalance, deducted;
// If cusEnt has less balance to deduct than 0, deduct the balance and set balance to 0
if (cusEnt.balance - toDeduct < 0) {
toDeduct -= cusEnt.balance;
if (cusEnt.balance! - toDeduct < 0) {
toDeduct -= cusEnt.balance!;
deducted = cusEnt.balance!;
newBalance = 0;
}
// Else, deduct the balance and set toDeduct to 0
else {
newBalance = cusEnt.balance - toDeduct;
newBalance = cusEnt.balance! - toDeduct;
deducted = toDeduct;
toDeduct = 0;
}
cusEnt.balance = newBalance;
await CustomerEntitlementService.update({
sb,
id: cusEnt.id,
@@ -224,6 +323,19 @@ export const updateCustomerBalance = async ({
balance: newBalance,
},
});
// // If cus ent has usage_allowed -> update balance
// if (cusEnt.usage_allowed) {
// await updateCusEntInStripe({
// cusEnt,
// cusPrices,
// org,
// env,
// customer,
// amountUsed: deducted,
// eventId: event.id + "_1",
// });
// }
}
}
@@ -234,12 +346,15 @@ export const updateCustomerBalance = async ({
// Deduct from usage-based price
const usageBasedEnt = cusEnts.find(
(cusEnt) => cusEnt.entitlement.usage_allowed
(cusEnt: CusEntWithEntitlement) => cusEnt.usage_allowed
);
if (usageBasedEnt) {
console.log("Deducting from usage-based price (entitlement)");
let newBalance = usageBasedEnt.balance - toDeduct;
let newBalance = usageBasedEnt.balance! - toDeduct;
// console.log("Cur balance", usageBasedEnt.balance);
// console.log("To deduct", toDeduct);
// console.log("New balance", newBalance);
await CustomerEntitlementService.update({
sb,
id: usageBasedEnt.id,
@@ -247,25 +362,31 @@ export const updateCustomerBalance = async ({
balance: newBalance,
},
});
// await updateCusEntInStripe({
// cusEnt: usageBasedEnt,
// cusPrices,
// org,
// env,
// customer,
// amountUsed: toDeduct,
// eventId: event.id + "_2",
// });
} else {
console.log("No usage-based entitlement found");
}
}
let featuresUpdated = Object.values(featureIdToDeduction).map(
(obj: any) => `(${obj.feature.id}: ${obj.deduction})`
);
console.log(` - Deducted ${featuresUpdated}`);
return cusEnts;
};
// MAIN FUNCTION
export const runUpdateBalanceTask = async (payload: any) => {
try {
const sb = createSupabaseClient();
// 1. Update customer balance
const { customer, features, event } = payload;
const { customer, features, event, org, env } = payload;
console.log("--------------------------------");
console.log("Inside updateBalanceTask...");
@@ -276,6 +397,8 @@ export const runUpdateBalanceTask = async (payload: any) => {
customer,
features,
event,
org,
env,
});
if (!cusEnts || cusEnts.length === 0) {
@@ -305,7 +428,7 @@ export const runUpdateBalanceTask = async (payload: any) => {
console.log(" ✅ No below threshold price found");
}
} catch (error) {
console.log(`Error updating customer balance: ${error}`);
console.log(`Error updating customer balance`);
console.log(error);
}
};

View File

@@ -69,4 +69,7 @@ export const ErrCode = {
// Cus Product
NoActiveCusProducts: "no_active_cus_products",
// Cus Price
GetCusPriceFailed: "get_cus_price_failed",
};