diff --git a/server/run.sh b/server/run.sh index e9e2d9c6a..3f2b7a3fa 100755 --- a/server/run.sh +++ b/server/run.sh @@ -23,7 +23,7 @@ elif [[ $filename == *"/tests/"* ]]; then elif [[ $filename == *".sh"* ]]; then $filename else - npx tsx $filename + NODE_ENV=development npx tsx $filename fi diff --git a/server/src/errors/logger.ts b/server/src/errors/logger.ts index a47724968..dc5c81753 100644 --- a/server/src/errors/logger.ts +++ b/server/src/errors/logger.ts @@ -2,34 +2,9 @@ import pino from "pino"; export const initLogger = () => { // Create separate streams for console and HyperDX - const streams: pino.StreamEntry[] = [ - // Pretty console output stream - // { - // level: process.env.NODE_ENV === "development" ? "debug" : "info", - // stream: pino.transport({ - // target: "pino-pretty", - // options: { - // colorize: true, - // translateTime: "UTC:yyyy-mm-dd HH:MM:ss", - // ignore: "pid,hostname,res,context,req,statusCode,worker", - // customColors: { - // default: "white", - // 60: "bgRed", - // 50: "red", - // 40: "yellow", - // 30: "green", - // 20: "blue", - // 10: "gray", - // message: "reset", - // greyMessage: "gray", - // time: "darkGray", - // }, - // }, - // }), - // }, - ]; + const streams: pino.StreamEntry[] = []; - if (process.env.NODE_ENV == "development") { + if (process.env.NODE_ENV === "development") { streams.push({ level: process.env.NODE_ENV === "development" ? "debug" : "info", stream: pino.transport({ diff --git a/server/src/external/stripe/stripeWebhooks.ts b/server/src/external/stripe/stripeWebhooks.ts index 8be9d555e..3eefcf9a0 100644 --- a/server/src/external/stripe/stripeWebhooks.ts +++ b/server/src/external/stripe/stripeWebhooks.ts @@ -88,8 +88,10 @@ stripeWebhookRouter.post( request.logtail = request.logtail.child({ context: { context: { - body: request.body, + // body: request.body, event_type: event.type, + event_id: event.id, + object_id: `${event.data?.object?.id}` || "N/A", authType: AuthType.Stripe, org_id: orgId, org_slug: org.slug, diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index 4614b5bd7..b9f07d684 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -20,6 +20,7 @@ import { ExtendedRequest } from "@/utils/models/Request.js"; import { handleCheckoutSub } from "./handleCheckoutCompleted/handleCheckoutSub.js"; import { handleRemainingSets } from "./handleCheckoutCompleted/handleRemainingSets.js"; import { getOptionsFromCheckoutSession } from "./handleCheckoutCompleted/getOptionsFromCheckout.js"; +import { getEntityInvoiceDescription } from "@/internal/entities/entityUtils/entityInvoiceUtils.js"; export const handleCheckoutSessionCompleted = async ({ req, @@ -130,6 +131,7 @@ export const handleCheckoutSessionCompleted = async ({ console.log("✅ checkout.completed: successfully created cus product"); const batchInsertInvoice: any = []; + for (const invoiceId of invoiceIds) { batchInsertInvoice.push( insertInvoiceFromAttach({ diff --git a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts index de74f77f5..8dbe80da8 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoiceCreated/handleInvoiceCreated.ts @@ -329,8 +329,6 @@ export const handleInvoiceCreated = async ({ let entity = await EntityService.getByInternalId({ db, internalId: internalEntityId, - orgId: org.id, - env, }); let feature = features.find( @@ -352,7 +350,7 @@ export const handleInvoiceCreated = async ({ feature, plural: false, capitalize: true, - })}: ${entity?.name} (ID: ${entity?.id})`, + })}: ${entDetails}`, }); } } catch (error: any) { diff --git a/server/src/internal/api/entities/EntityService.ts b/server/src/internal/api/entities/EntityService.ts index 385ba82f4..9e923e582 100644 --- a/server/src/internal/api/entities/EntityService.ts +++ b/server/src/internal/api/entities/EntityService.ts @@ -45,21 +45,13 @@ export class EntityService { static async getByInternalId({ db, internalId, - orgId, - env, }: { db: DrizzleCli; internalId: string; - orgId: string; - env: string; }) { let entity = await db.query.entities.findFirst({ where: (entities, { eq, and }) => - and( - eq(entities.internal_id, internalId), - eq(entities.org_id, orgId), - eq(entities.env, env), - ), + and(eq(entities.internal_id, internalId)), }); if (!entity) { throw new RecaseError({ diff --git a/server/src/internal/customers/add-product/handleCreateCheckout.ts b/server/src/internal/customers/add-product/handleCreateCheckout.ts index b39e832cf..6f60cd890 100644 --- a/server/src/internal/customers/add-product/handleCreateCheckout.ts +++ b/server/src/internal/customers/add-product/handleCreateCheckout.ts @@ -13,6 +13,8 @@ import { getNextStartOfMonthUnix } from "@/internal/products/prices/billingInter import { APIVersion } from "@autumn/shared"; import { SuccessCode } from "@autumn/shared"; import { notNullish } from "@/utils/genUtils.js"; +import { getEntityInvoiceDescription } from "@/internal/entities/entityUtils/entityInvoiceUtils.js"; +import Stripe from "stripe"; export const handleCreateCheckout = async ({ req, @@ -67,13 +69,14 @@ export const handleCreateCheckout = async ({ ); } - const subscriptionData = isRecurring + const subscriptionData: + | Stripe.Checkout.SessionCreateParams.SubscriptionData + | undefined = isRecurring ? { trial_end: freeTrial && !attachParams.disableFreeTrial ? freeTrialToStripeTimestamp({ freeTrial }) : undefined, - // metadata: subMeta, billing_cycle_anchor: billingCycleAnchorUnixSeconds, } : undefined; diff --git a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts index 32799fa5b..b7004f94b 100644 --- a/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts +++ b/server/src/internal/customers/cusProducts/cusProductUtils/convertCusProduct.ts @@ -49,12 +49,14 @@ export const cusProductsToCusEnts = ({ cusProducts, inStatuses = [CusProductStatus.Active], reverseOrder = false, + featureId, }: { cusProducts: FullCusProduct[]; inStatuses?: CusProductStatus[]; reverseOrder?: boolean; + featureId?: string; }) => { - const cusEnts: FullCustomerEntitlement[] = []; + let cusEnts: FullCustomerEntitlement[] = []; for (const cusProduct of cusProducts) { if (!inStatuses.includes(cusProduct.status)) { @@ -69,6 +71,12 @@ export const cusProductsToCusEnts = ({ ); } + if (featureId) { + cusEnts = cusEnts.filter( + (cusEnt) => cusEnt.entitlement.feature_id === featureId, + ); + } + sortCusEntsForDeduction(cusEnts, reverseOrder); return cusEnts; diff --git a/server/src/internal/customers/cusUtils/createNewCustomer.ts b/server/src/internal/customers/cusUtils/createNewCustomer.ts index 46a83e5c1..471c3ef83 100644 --- a/server/src/internal/customers/cusUtils/createNewCustomer.ts +++ b/server/src/internal/customers/cusUtils/createNewCustomer.ts @@ -29,25 +29,17 @@ import { export const createNewCustomer = async ({ req, - db, - org, - env, customer, nextResetAt, - processor, - logger, createDefaultProducts = true, }: { req: ExtendedRequest; - db: DrizzleCli; - org: Organization; - env: AppEnv; customer: CreateCustomer; nextResetAt?: number; - processor?: any; - logger: any; createDefaultProducts?: boolean; }) => { + const { db, org, env, logger } = req; + logger.info( `Creating customer: ${customer.email || customer.id}, org: ${org.slug}`, ); @@ -65,17 +57,24 @@ export const createNewCustomer = async ({ const customerData: Customer = { ...parsedCustomer, + name: parsedCustomer.name || "", email: nonFreeProds.length > 0 && !parsedCustomer.email ? `${parsedCustomer.id}@invoices.useautumn.com` : parsedCustomer.email || "", + metadata: parsedCustomer.metadata || {}, internal_id: generateId("cus"), org_id: org.id, created_at: Date.now(), env, - processor, + processor: parsedCustomer.stripe_id + ? { + id: parsedCustomer.stripe_id, + type: "stripe", + } + : undefined, }; // Check if stripeCli exists diff --git a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts index ed9234ec4..6d837da3c 100644 --- a/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts +++ b/server/src/internal/customers/cusUtils/getOrCreateCustomer.ts @@ -74,7 +74,6 @@ export const getOrCreateCustomer = async ({ try { customer = await handleCreateCustomer({ req, - db, cusData: { id: customerId, name: customerData?.name, @@ -82,9 +81,6 @@ export const getOrCreateCustomer = async ({ fingerprint: customerData?.fingerprint, metadata: customerData?.metadata || {}, }, - org, - env, - logger, }); customer = await CusService.getFull({ diff --git a/server/src/internal/customers/handlers/handleCreateCustomer.ts b/server/src/internal/customers/handlers/handleCreateCustomer.ts index 4e9131c5b..c9231263f 100644 --- a/server/src/internal/customers/handlers/handleCreateCustomer.ts +++ b/server/src/internal/customers/handlers/handleCreateCustomer.ts @@ -59,23 +59,15 @@ export const initStripeCusAndProducts = async ({ const handleIdIsNull = async ({ req, - db, - org, - env, newCus, - logger, - processor, createDefaultProducts, }: { req: ExtendedRequest; - db: DrizzleCli; - org: Organization; - env: AppEnv; newCus: CreateCustomer; - logger: any; - processor?: any; createDefaultProducts?: boolean; }) => { + const { db, org, env, logger } = req; + // 1. ID is null if (!newCus.email) { throw new RecaseError({ @@ -113,12 +105,7 @@ const handleIdIsNull = async ({ const createdCustomer = await createNewCustomer({ req, - db, - org, - env, customer: newCus, - logger, - processor, createDefaultProducts, }); @@ -128,23 +115,15 @@ const handleIdIsNull = async ({ // CAN ALSO USE DURING MIGRATION... export const handleCreateCustomerWithId = async ({ req, - db, - org, - env, - logger, newCus, - processor, createDefaultProducts = true, }: { req: ExtendedRequest; - db: DrizzleCli; - org: Organization; - env: AppEnv; - logger: any; newCus: CreateCustomer; - processor?: any; createDefaultProducts?: boolean; }) => { + const { db, org, env, logger } = req; + // 1. Get by ID let existingCustomer = await CusService.get({ db, @@ -155,7 +134,7 @@ export const handleCreateCustomerWithId = async ({ if (existingCustomer) { logger.info( - `POST /customers, existing customer found: ${existingCustomer.id} (org: ${org.slug})`, + `Customer already exists, skipping creation: ${existingCustomer.id}`, ); return existingCustomer; } @@ -191,33 +170,18 @@ export const handleCreateCustomerWithId = async ({ // 2. Handle email step... return await createNewCustomer({ req, - db, - org, - env, customer: newCus, - logger, - processor, createDefaultProducts, }); }; export const handleCreateCustomer = async ({ req, - db, cusData, - org, - env, - logger, - processor, createDefaultProducts = true, }: { req: ExtendedRequest; - db: DrizzleCli; cusData: CreateCustomer; - org: Organization; - env: AppEnv; - logger: any; - processor?: any; createDefaultProducts?: boolean; }) => { const newCus = CreateCustomerSchema.parse(cusData); @@ -227,23 +191,13 @@ export const handleCreateCustomer = async ({ if (newCus.id === null) { createdCustomer = await handleIdIsNull({ req, - db, - org, - env, newCus, - logger, - processor, createDefaultProducts, }); } else { createdCustomer = await handleCreateCustomerWithId({ req, - db, - org, - env, - logger, newCus, - processor, createDefaultProducts, }); } diff --git a/server/src/internal/entities/entityUtils/entityInvoiceUtils.ts b/server/src/internal/entities/entityUtils/entityInvoiceUtils.ts new file mode 100644 index 000000000..bade25a6a --- /dev/null +++ b/server/src/internal/entities/entityUtils/entityInvoiceUtils.ts @@ -0,0 +1,48 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { EntityService } from "@/internal/api/entities/EntityService.js"; +import { Feature, getFeatureName } from "@autumn/shared"; +import { AppEnv, Entity } from "autumn-js"; + +export const getEntityInvoiceDescription = async ({ + db, + internalEntityId, + features, + logger, +}: { + db: DrizzleCli; + internalEntityId: string; + features: Feature[]; + logger: any; +}) => { + try { + let entity = await EntityService.getByInternalId({ + db, + internalId: internalEntityId, + }); + + let feature = features.find( + (f) => f.internal_id == entity?.internal_feature_id, + ); + + let entDetails = ""; + if (entity.name) { + entDetails = `${entity.name}${entity.id ? ` (ID: ${entity.id})` : ""}`; + } else if (entity.id) { + entDetails = `${entity.id}`; + } + + if (feature && entDetails) { + let featureName = getFeatureName({ + feature, + plural: false, + capitalize: true, + }); + return `${featureName}: ${entDetails}`; + } + + return ""; + } catch (error) { + logger.error(`Failed to get entity invoice description`, { error }); + return ""; + } +}; diff --git a/server/src/internal/products/pricecn/pricecnUtils.ts b/server/src/internal/products/pricecn/pricecnUtils.ts index 9dc1d6f43..642982af3 100644 --- a/server/src/internal/products/pricecn/pricecnUtils.ts +++ b/server/src/internal/products/pricecn/pricecnUtils.ts @@ -134,17 +134,23 @@ export const getPricecnPrice = ({ if (isPriceItem(priceItem)) { return { + ...priceItem, primaryText: getPriceText({ item: priceItem, org }), secondaryText: priceItem.interval ? `per ${priceItem.interval}` : " ", }; } else { let feature = features.find((f) => f.id == priceItem.feature_id); - return featurePricetoPricecnItem({ + let texts = featurePricetoPricecnItem({ feature, item: priceItem, org, isMainPrice, }); + return { + ...priceItem, + primaryText: texts.primaryText, + secondaryText: texts.secondaryText, + }; } }; diff --git a/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts b/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts new file mode 100644 index 000000000..9e653be3c --- /dev/null +++ b/server/src/internal/products/prices/priceUtils/constructPriceUtils.ts @@ -0,0 +1,29 @@ +import Stripe from "stripe"; +import { constructPrice } from "../priceUtils.js"; +import { FullProduct, PriceType } from "@autumn/shared"; +import { generateId } from "@/utils/genUtils.js"; +import { subItemToAutumnInterval } from "tests/utils/stripeUtils.js"; + +export const subItemToFixedPrice = ({ + subItem, + product, + basePrice, +}: { + subItem: Stripe.SubscriptionItem; + product: FullProduct; + basePrice?: number; +}) => { + const { price } = subItem; + + return constructPrice({ + internalProductId: product.internal_id, + isCustom: true, + orgId: product.org_id, + fixedConfig: { + type: PriceType.Fixed, + amount: basePrice || (price.unit_amount || 0) / 100, + interval: subItemToAutumnInterval(subItem)!, + stripe_price_id: price.id, + }, + }); +}; diff --git a/server/src/internal/products/prices/priceUtils/findPriceUtils.ts b/server/src/internal/products/prices/priceUtils/findPriceUtils.ts index ad3c85711..8a6fdbe5e 100644 --- a/server/src/internal/products/prices/priceUtils/findPriceUtils.ts +++ b/server/src/internal/products/prices/priceUtils/findPriceUtils.ts @@ -116,3 +116,13 @@ export const priceToProduct = ({ (p: Product) => p.internal_id == price.internal_product_id, ); }; + +export const filterByBillingType = ({ + prices, + billingType, +}: { + prices: Price[]; + billingType: BillingType; +}) => { + return prices.filter((p) => getBillingType(p.config) == billingType); +}; diff --git a/server/src/utils/importUtils/addProductFromSubs.ts b/server/src/utils/importUtils/addProductFromSubs.ts new file mode 100644 index 000000000..56e58fa03 --- /dev/null +++ b/server/src/utils/importUtils/addProductFromSubs.ts @@ -0,0 +1,168 @@ +import Stripe from "stripe"; +import { stripeToAutumnSubStatus } from "@/external/stripe/stripeSubUtils.js"; +import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; +import { isFreeProduct } from "@/internal/products/productUtils.js"; +import { SubService } from "@/internal/subscriptions/SubService.js"; +import { constructSub } from "@/internal/subscriptions/subUtils.js"; +import { + FullCustomer, + FullProduct, + Price, + EntitlementWithFeature, + CusProductStatus, + UsagePriceConfig, + BillingInterval, +} from "@autumn/shared"; + +import { notNullish } from "../genUtils.js"; +import { ExtendedRequest } from "../models/Request.js"; +import { isUsagePrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { subToAutumnInterval } from "@/external/stripe/utils.js"; +import { prices as priceTable } from "@autumn/shared"; +import { PriceService } from "@/internal/products/prices/PriceService.js"; + +export const addProductFromSubs = async ({ + req, + autumnCus, + autumnProduct, + stripeSubs, + prices, + entitlements, + force = false, + isCustom = false, + anchorToUnix, +}: { + req: ExtendedRequest; + autumnCus: FullCustomer; + autumnProduct: FullProduct; + stripeSubs: Stripe.Subscription[]; + prices?: Price[]; + entitlements?: EntitlementWithFeature[]; + force?: boolean; + isCustom?: boolean; + anchorToUnix?: number; +}) => { + const { db, logger, org, env } = req; + + const cusProducts = autumnCus.customer_products; + const entity = autumnCus.entity; + + let mainCusProduct = cusProducts.find( + (cp) => + !cp.product.is_add_on && + cp.product_id == autumnProduct.id && + (notNullish(entity) + ? cp.internal_entity_id == entity!.internal_id + : true), + ); + + if (mainCusProduct && !force) { + let prices = mainCusProduct.customer_prices.map((cp) => cp.price); + let isFree = isFreeProduct(prices); + + if (!isFree) { + logger.info( + `Customer ${ + autumnCus.id || autumnCus.email + } already has non-free free product: ${ + mainCusProduct.product.name + }, skipping...`, + ); + return mainCusProduct; + } + } + + // Handle if trialing + let trialEndsAt = stripeSubs[0].trial_end + ? stripeSubs[0].trial_end * 1000 + : null; + + // 1. Insert custom prices... + let customPrices = prices?.filter((p) => p.is_custom); + if (customPrices && customPrices.length > 0) { + await PriceService.upsert({ + db, + data: customPrices, + }); + } + + let newCusProduct = await createFullCusProduct({ + db, + attachParams: { + replaceables: [], + customer: autumnCus, + product: autumnProduct, + org, + prices: notNullish(prices) ? prices! : autumnProduct.prices, + entitlements: notNullish(entitlements) + ? entitlements! + : autumnProduct.entitlements, + freeTrial: autumnProduct.free_trial || null, + optionsList: [], + entities: [], + cusProducts: cusProducts, + features: [], + internalEntityId: entity?.internal_id, + entityId: entity?.id, + isCustom: isCustom, + }, + logger, + trialEndsAt: trialEndsAt || undefined, + subscriptionIds: stripeSubs.map((s) => s.id), + anchorToUnix: anchorToUnix || stripeSubs[0].current_period_end * 1000, + + subscriptionStatus: stripeToAutumnSubStatus( + stripeSubs[0].status, + ) as CusProductStatus, + + canceledAt: stripeSubs[0].canceled_at + ? stripeSubs[0].canceled_at * 1000 + : null, + + createdAt: stripeSubs[0].created * 1000, + sendWebhook: false, + }); + + logger.info( + `Added product ${autumnProduct.name} to customer ${autumnCus.name}`, + ); + + // Create sub + let usageFeatures = autumnProduct.prices + .filter((p) => isUsagePrice({ price: p })) + .map((p) => (p.config as UsagePriceConfig).internal_feature_id); + + for (const sub of stripeSubs) { + let subFromDb = await SubService.getInStripeIds({ + db, + ids: [sub.id], + }); + + let subInterval = subToAutumnInterval(sub); + + if (subFromDb.length === 0) { + await SubService.createSub({ + db, + sub: constructSub({ + stripeId: sub.id, + usageFeatures: + subInterval == BillingInterval.Month ? usageFeatures : [], + orgId: org.id, + env, + currentPeriodStart: sub.current_period_start, + currentPeriodEnd: sub.current_period_end, + }), + }); + logger.info(`Created sub ${sub.id} in DB`); + } else { + logger.info(`Sub ${sub.id} already exists in DB`); + } + } + + autumnCus.customer_products = [ + ...(autumnCus.customer_products || []), + newCusProduct!, + ]; + + return newCusProduct; +}; diff --git a/server/src/utils/importUtils/addUsagePricesToSub.ts b/server/src/utils/importUtils/addUsagePricesToSub.ts new file mode 100644 index 000000000..d952bdc16 --- /dev/null +++ b/server/src/utils/importUtils/addUsagePricesToSub.ts @@ -0,0 +1,52 @@ +import { findStripeItemForPrice } from "@/external/stripe/stripeSubUtils/stripeSubItemUtils.js"; +import { filterByBillingType } from "@/internal/products/prices/priceUtils/findPriceUtils.js"; +import { BillingType, FullProduct, UsagePriceConfig } from "@autumn/shared"; +import Stripe from "stripe"; + +export const addContUsePricesToSub = async ({ + stripe, + sub, + autumnProduct, + quantity, + logger, +}: { + stripe: Stripe; + sub: Stripe.Subscription; + autumnProduct: FullProduct; + quantity: number; + logger: any; +}) => { + const usagePrices = filterByBillingType({ + prices: autumnProduct.prices, + billingType: BillingType.InArrearProrated, + }); + + logger.info(`Adding ${usagePrices.length} cont use prices to sub`); + + for (const usagePrice of usagePrices) { + const config = usagePrice.config as UsagePriceConfig; + const latestSub = await stripe.subscriptions.retrieve(sub.id); + let subItem = findStripeItemForPrice({ + price: usagePrice, + stripeItems: latestSub.items.data, + }); + + if (subItem) { + logger.info(`Sub already has price for ${config.feature_id}`); + continue; + } + + const newSubItem = await stripe.subscriptionItems.create({ + subscription: sub.id, + price: usagePrice.config.stripe_price_id!, + proration_behavior: "none", + quantity, + }); + + // logger.info(`New sub item:`, { + // newSubItem, + // }); + + logger.info(`Successfully added ${config.feature_id} to sub`); + } +}; diff --git a/server/src/utils/importUtils/importUtils.ts b/server/src/utils/importUtils/importUtils.ts new file mode 100644 index 000000000..f7b981516 --- /dev/null +++ b/server/src/utils/importUtils/importUtils.ts @@ -0,0 +1,27 @@ +import Stripe from "stripe"; +import { subItemToFixedPrice } from "@/internal/products/prices/priceUtils/constructPriceUtils.js"; +import { isFixedPrice } from "@/internal/products/prices/priceUtils/usagePriceUtils/classifyUsagePrice.js"; +import { FullProduct } from "@autumn/shared"; + +// Scenario 1: Replace base price with new base price +export const replaceBasePrice = async ({ + subItems, + autumnProduct, + basePrice, +}: { + subItems: Stripe.SubscriptionItem[]; + autumnProduct: FullProduct; + basePrice?: number; +}) => { + let prices = autumnProduct.prices.filter((p) => !isFixedPrice({ price: p })); + + // Get first sub item + const subItem = subItems[0]; + const customPrice = subItemToFixedPrice({ + subItem, + product: autumnProduct, + basePrice, + }); + + return [customPrice, ...prices]; +}; diff --git a/server/src/utils/importUtils/parseCsv.ts b/server/src/utils/importUtils/parseCsv.ts new file mode 100644 index 000000000..0ca750c38 --- /dev/null +++ b/server/src/utils/importUtils/parseCsv.ts @@ -0,0 +1,43 @@ +import { FeatureOptions } from "@autumn/shared"; +import csvParser from "csv-parser"; +import fs from "fs"; + +export interface ImportCustomer { + id: string; + name: string; + email?: string; + stripe_id: string; + product_id: string; + base_price?: number; + options?: FeatureOptions[]; + // business_id;name;email;active_pass_count;Stripe id;Base price;free_trial_end +} +export const parseCsv = (slug: string): Promise => { + const path = `scripts/customers/${slug}/data.csv`; + const results: ImportCustomer[] = []; + + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(path); + stream + .pipe(csvParser({ separator: ";" })) + .on("data", (data) => + results.push({ + id: data.business_id, + name: data.name, + email: data.email, + stripe_id: data["Stripe id"], + // base_price: data["Base price"], + product_id: "standard_subscription", + base_price: data["Base price"], + options: [ + { + feature_id: "active_passes", + quantity: data["active_pass_count"], + }, + ], + }), + ) + .on("end", () => resolve(results)) + .on("error", (error) => reject(error)); + }); +}; diff --git a/server/src/utils/importUtils/updateUsages.ts b/server/src/utils/importUtils/updateUsages.ts new file mode 100644 index 000000000..4c02855f4 --- /dev/null +++ b/server/src/utils/importUtils/updateUsages.ts @@ -0,0 +1,37 @@ +import { DrizzleCli } from "@/db/initDrizzle.js"; +import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService.js"; +import { ACTIVE_STATUSES } from "@/internal/customers/cusProducts/CusProductService.js"; +import { cusProductsToCusEnts } from "@/internal/customers/cusProducts/cusProductUtils/convertCusProduct.js"; +import { CusProductStatus, FullCustomer } from "@autumn/shared"; + +export const updateUsages = async ({ + featureId, + usage, + fullCus, + db, +}: { + featureId: string; + usage: number; + fullCus: FullCustomer; + db: DrizzleCli; +}) => { + let cusEnts = cusProductsToCusEnts({ + cusProducts: fullCus.customer_products, + inStatuses: ACTIVE_STATUSES, + featureId, + }); + if (cusEnts.length === 0) { + throw new Error(`No cus ent for ${featureId}`); + } + + let cusEnt = cusEnts[0]; + let newBalance = cusEnt.balance! - usage; + + await CusEntService.update({ + db, + id: cusEnt.id, + updates: { + balance: newBalance, + }, + }); +}; diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index 9006762e4..66e41459c 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -24,7 +24,8 @@ export const CreateCustomerSchema = z.object({ name: z.string().nullish(), email: z.string().nullish(), fingerprint: z.string().nullish(), - metadata: z.record(z.any()).nullish().default({}), + metadata: z.record(z.any()).default({}).nullish(), + stripe_id: z.string().nullish(), }); export const CustomerDataSchema = z.object({ diff --git a/vite/src/utils/product/product-item/validateProductItem.ts b/vite/src/utils/product/product-item/validateProductItem.ts index 3a4b8193c..05bac1b65 100644 --- a/vite/src/utils/product/product-item/validateProductItem.ts +++ b/vite/src/utils/product/product-item/validateProductItem.ts @@ -1,7 +1,7 @@ import { invalidNumber, notNullish } from "@/utils/genUtils"; import { Feature, FeatureUsageType, ProductItem } from "@autumn/shared"; import { toast } from "sonner"; -import { isFeatureItem } from "../getItemType"; +import { isFeatureItem, isFeaturePriceItem } from "../getItemType"; export const validateProductItem = ({ item, @@ -93,7 +93,11 @@ export const validateProductItem = ({ toast.error("Please enter valid billing units"); return null; } else { - item.billing_units = Number(item.billing_units); + if (isFeaturePriceItem(item)) { + item.billing_units = Number(item.billing_units); + } else { + item.billing_units = undefined; + } } return item; diff --git a/vite/src/views/products/product/ProductView.tsx b/vite/src/views/products/product/ProductView.tsx index 48e85ff01..f606f374c 100644 --- a/vite/src/views/products/product/ProductView.tsx +++ b/vite/src/views/products/product/ProductView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import LoadingScreen from "@/views/general/LoadingScreen"; import { useAxiosSWR } from "@/services/useAxiosSwr"; @@ -19,7 +19,6 @@ import ProductSidebar from "./ProductSidebar"; import { FeaturesContext } from "@/views/features/FeaturesContext"; import ProductViewBreadcrumbs from "./components/ProductViewBreadcrumbs"; import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog"; -import { sortProductItems } from "@/utils/productUtils"; import { useProductData } from "./hooks/useProductData"; import { useProductChangedAlert } from "./hooks/useProductChangedAlert"; diff --git a/vite/src/views/products/product/product-item/ProductItemConfig.tsx b/vite/src/views/products/product/product-item/ProductItemConfig.tsx index 230e06994..1eb08631f 100644 --- a/vite/src/views/products/product/product-item/ProductItemConfig.tsx +++ b/vite/src/views/products/product/product-item/ProductItemConfig.tsx @@ -63,18 +63,26 @@ export const ProductItemConfig = () => { }); } else { const showProration = shouldShowProrationConfig({ item, features }); - const newConfig = !showProration ? null : item.config; const resetUsageWhenEnabled = feature.config?.usage_type == FeatureUsageType.Continuous ? false : true; - setItem({ + const newItem = { ...item, feature_type: feature.config?.usage_type, reset_usage_when_enabled: resetUsageWhenEnabled, - config: newConfig, - }); + }; + + const newConfig = !showProration ? undefined : item.config; + + if (newConfig) { + newItem.config = newConfig; + } else { + delete newItem.config; + } + + setItem(newItem); } } }, [item.feature_id, item.usage_model]); diff --git a/vite/src/views/products/product/product-item/UpdateProductItem.tsx b/vite/src/views/products/product/product-item/UpdateProductItem.tsx index 9f8b96df1..c9f983389 100644 --- a/vite/src/views/products/product/product-item/UpdateProductItem.tsx +++ b/vite/src/views/products/product/product-item/UpdateProductItem.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ProductItemConfig } from "./ProductItemConfig"; import { ProductItem } from "@autumn/shared"; import { ProductItemContext } from "./ProductItemContext"; @@ -27,11 +27,12 @@ export default function UpdateProductItem({ const { product, setProduct, features } = useProductContext(); const [showCreateFeature, setShowCreateFeature] = useState(false); - const handleUpdateProductItem = (show: any) => { + const handleUpdateProductItem = () => { const validatedItem = validateProductItem({ item: selectedItem!, features, }); + if (!validatedItem) return; if (notNullish(selectedIndex)) { const newProduct = { ...product }; diff --git a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx index f2771e99c..ec45e6bc2 100644 --- a/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx +++ b/vite/src/views/products/product/product-item/product-item-config/components/IncludedUsage.tsx @@ -34,10 +34,12 @@ export const IncludedUsage = () => { } type={item.included_usage === Infinite ? "text" : "number"} onChange={(e) => { - setItem({ + const newItem = { ...item, included_usage: e.target.value, - }); + }; + + setItem(newItem); }} /> { placeholder={`eg. 100 ${featureName}`} onKeyDown={(e) => { if (e.key === "Enter") { - handleEnterClick(); + if (popoverOpen) { + handleEnterClick(); + } } }} onBlur={handleEnterClick}