diff --git a/frontend/src/utils/product/entitlementUtils.ts b/frontend/src/utils/product/entitlementUtils.ts index 6534c5156..4bc55b31a 100644 --- a/frontend/src/utils/product/entitlementUtils.ts +++ b/frontend/src/utils/product/entitlementUtils.ts @@ -1,4 +1,10 @@ -import { Feature } from "@autumn/shared"; +import { + Entitlement, + Feature, + Price, + PriceType, + UsagePriceConfig, +} from "@autumn/shared"; export const getFeature = ( internalFeatureId: string | undefined, diff --git a/frontend/src/views/products/product/prices/CreateUsagePrice.tsx b/frontend/src/views/products/product/prices/CreateUsagePrice.tsx index 48b2b7e3d..9c1bedffa 100644 --- a/frontend/src/views/products/product/prices/CreateUsagePrice.tsx +++ b/frontend/src/views/products/product/prices/CreateUsagePrice.tsx @@ -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} > diff --git a/frontend/src/views/products/product/prices/PricingConfig.tsx b/frontend/src/views/products/product/prices/PricingConfig.tsx index 3a7c7493a..4511a0749 100644 --- a/frontend/src/views/products/product/prices/PricingConfig.tsx +++ b/frontend/src/views/products/product/prices/PricingConfig.tsx @@ -93,6 +93,7 @@ export const PricingConfig = ({ setConfig={setUsageConfig} usageTiers={usageTiers} setUsageTiers={setUsageTiers} + price={price} /> diff --git a/server/src/cron.ts b/server/src/cron.ts index 71ab74b8e..4bd7df8ee 100644 --- a/server/src/cron.ts +++ b/server/src/cron.ts @@ -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!), diff --git a/server/src/external/stripe/stripeMeterUtils.ts b/server/src/external/stripe/stripeMeterUtils.ts new file mode 100644 index 000000000..5ad538c38 --- /dev/null +++ b/server/src/external/stripe/stripeMeterUtils.ts @@ -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, + }); +}; diff --git a/server/src/external/stripe/stripeOnboardingUtils.ts b/server/src/external/stripe/stripeOnboardingUtils.ts index ec06a2020..d45943ff0 100644 --- a/server/src/external/stripe/stripeOnboardingUtils.ts +++ b/server/src/external/stripe/stripeOnboardingUtils.ts @@ -23,6 +23,8 @@ export const createWebhookEndpoint = async ( "customer.subscription.deleted", "checkout.session.completed", "invoice.paid", + "invoice.created", + "invoice.finalized", ], }); diff --git a/server/src/external/stripe/stripePriceUtils.ts b/server/src/external/stripe/stripePriceUtils.ts index 7e037ac45..aa886e224 100644 --- a/server/src/external/stripe/stripePriceUtils.ts +++ b/server/src/external/stripe/stripePriceUtils.ts @@ -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", + }, + }); +}; diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index cd5f415cd..7e571e41e 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -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({ diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated.ts new file mode 100644 index 000000000..f978d7f42 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated.ts @@ -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}`, + // }); + } + } +}; diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index f4ddf5210..014a17274 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -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"), + }); + } } } } diff --git a/server/src/internal/api/customers/cusUtils.ts b/server/src/internal/api/customers/cusUtils.ts index 44d48f565..6725eb0b7 100644 --- a/server/src/internal/api/customers/cusUtils.ts +++ b/server/src/internal/api/customers/cusUtils.ts @@ -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 }; +}; diff --git a/server/src/internal/api/events/eventRouter.ts b/server/src/internal/api/events/eventRouter.ts index f731aad77..e8c8d5a67 100644 --- a/server/src/internal/api/events/eventRouter.ts +++ b/server/src/internal/api/events/eventRouter.ts @@ -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, }); } }; diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 83bf6f456..542a30235 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -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; + } } diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index c41b934fb..eb5c6b00d 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -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); diff --git a/server/src/internal/customers/entitlements/CusEntitlementService.ts b/server/src/internal/customers/entitlements/CusEntitlementService.ts index a71e62e9c..00ad9bccb 100644 --- a/server/src/internal/customers/entitlements/CusEntitlementService.ts +++ b/server/src/internal/customers/entitlements/CusEntitlementService.ts @@ -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; + } } diff --git a/server/src/internal/customers/entitlements/cusEntUtils.ts b/server/src/internal/customers/entitlements/cusEntUtils.ts index 9ed09e067..fe16ec398 100644 --- a/server/src/internal/customers/entitlements/cusEntUtils.ts +++ b/server/src/internal/customers/entitlements/cusEntUtils.ts @@ -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})`); +}; diff --git a/server/src/internal/customers/prices/CusPriceService.tsx b/server/src/internal/customers/prices/CusPriceService.tsx new file mode 100644 index 000000000..63c9660f5 --- /dev/null +++ b/server/src/internal/customers/prices/CusPriceService.tsx @@ -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; + } +} diff --git a/server/src/internal/customers/products/CusProductService.ts b/server/src/internal/customers/products/CusProductService.ts index 621f32566..fd05d3a61 100644 --- a/server/src/internal/customers/products/CusProductService.ts +++ b/server/src/internal/customers/products/CusProductService.ts @@ -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, diff --git a/server/src/internal/prices/priceInitUtils.ts b/server/src/internal/prices/priceInitUtils.ts index a593d5b80..5b48659d5 100644 --- a/server/src/internal/prices/priceInitUtils.ts +++ b/server/src/internal/prices/priceInitUtils.ts @@ -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)) { diff --git a/server/src/internal/prices/priceUtils.ts b/server/src/internal/prices/priceUtils.ts index 5df1900db..c1211d513 100644 --- a/server/src/internal/prices/priceUtils.ts +++ b/server/src/internal/prices/priceUtils.ts @@ -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; + } +}; diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 5df1d9df9..26757dbdd 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -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; + }); +}; diff --git a/server/src/queue/queue.ts b/server/src/queue/queue.ts index 51802cac9..7d42207fc 100644 --- a/server/src/queue/queue.ts +++ b/server/src/queue/queue.ts @@ -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); } diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index aaf585133..2e3d86844 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -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); } }; diff --git a/shared/errors/errCode.ts b/shared/errors/errCode.ts index 8b9148647..72deb9dc3 100644 --- a/shared/errors/errCode.ts +++ b/shared/errors/errCode.ts @@ -69,4 +69,7 @@ export const ErrCode = { // Cus Product NoActiveCusProducts: "no_active_cus_products", + + // Cus Price + GetCusPriceFailed: "get_cus_price_failed", };