From 9ae0b278e8e07a9bd8c8ce3a1c8fb1c6cbc71f3b Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 15 Apr 2025 22:31:17 +0100 Subject: [PATCH] added update product to onboarding view --- package-lock.json | 8 ++ server/src/external/stripe/utils.ts | 3 +- server/src/external/webhooks/clerkWebhooks.ts | 9 +- .../internal/api/features/featureApiRouter.ts | 6 +- .../api/products/handleUpdateProduct.ts | 50 +++++++--- .../api/products/handleVersionProduct.ts | 1 + .../internal/api/products/productRouter.ts | 15 +-- .../customers/entitlements/getCusBalances.ts | 2 +- .../src/internal/features/FeatureService.ts | 6 +- server/src/internal/orgs/orgRouter.ts | 2 +- .../products/entitlements/entitlementUtils.ts | 13 ++- .../product-items/itemIntervalUtils.ts | 7 +- .../products/product-items/mapFromItem.ts | 14 ++- .../products/product-items/mapToItem.ts | 1 + .../product-items/productItemInitUtils.ts | 1 + .../product-items/productItemUtils.ts | 6 +- .../product-items/validateProductItems.ts | 15 ++- server/src/internal/public/publicAttach.ts | 25 +++-- .../internal/rewards/RewardProgramService.ts | 28 +++++- server/src/websockets/initWs.ts | 93 ++++++++---------- server/test.sh | 12 +-- server/tests/alex/utils.ts | 10 +- server/tests/basic/07_downgrade.ts | 2 +- shared/errors/errCode.ts | 1 + .../models/productModels/entitlementModels.ts | 2 +- vite/package.json | 1 + vite/src/app/layout.tsx | 7 +- vite/src/components/general/AdminHover.tsx | 3 +- vite/src/components/ui/button.tsx | 2 +- vite/src/views/onboarding/OnboardingView.tsx | 12 +-- .../onboarding/components/OptionButton.tsx | 2 +- .../onboarding-steps/01_CreateOrg.tsx | 8 +- .../onboarding-steps/02_ConnectStripe.tsx | 2 +- .../onboarding-steps/03_CreateProduct.tsx | 40 ++++++-- .../onboarding-steps/04_CreateSecretKey.tsx | 2 +- .../views/products/product/ProductView.tsx | 3 - .../product/prices/CreateFixedPrice.tsx | 2 +- .../products/product/prices/PricingConfig.tsx | 2 +- .../product-item/ProductItemConfig.tsx | 97 +------------------ .../components/ConfigWithFeature.tsx | 18 ++-- .../product-item/components/SelectCycle.tsx | 4 +- 41 files changed, 280 insertions(+), 257 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98247a6d5..657da2381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4512,6 +4512,13 @@ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -15447,6 +15454,7 @@ "@eslint/js": "^9.21.0", "@types/lodash": "^4.17.16", "@types/node": "^22.13.10", + "@types/prismjs": "^1.26.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", diff --git a/server/src/external/stripe/utils.ts b/server/src/external/stripe/utils.ts index 2d7e90dd2..a41faaecd 100644 --- a/server/src/external/stripe/utils.ts +++ b/server/src/external/stripe/utils.ts @@ -5,6 +5,7 @@ import { BillingInterval, Feature, FullProduct, + Infinite, Organization, UsagePriceConfig, } from "@autumn/shared"; @@ -75,7 +76,7 @@ export const calculateMetered1Price = ({ const tier = usageConfig.usage_tiers[i]; let amtUsed; - if (tier.to == -1) { + if (tier.to == -1 || tier.to == Infinite) { amtUsed = usage; } else { amtUsed = Math.min(usage, tier.to); diff --git a/server/src/external/webhooks/clerkWebhooks.ts b/server/src/external/webhooks/clerkWebhooks.ts index be7a8d641..c73cfe3dc 100644 --- a/server/src/external/webhooks/clerkWebhooks.ts +++ b/server/src/external/webhooks/clerkWebhooks.ts @@ -80,8 +80,8 @@ const createDefaultProducts = async ({ const defaultProducts = [ { - id: "free", - name: "Free", + id: "free-example", + name: "Free (Example)", env: AppEnv.Sandbox, is_default: true, entitlements: [ @@ -96,8 +96,8 @@ const createDefaultProducts = async ({ prices: [], }, { - id: "pro", - name: "Pro", + id: "pro-example", + name: "Pro (Example)", env: AppEnv.Sandbox, is_default: false, entitlements: [ @@ -139,6 +139,7 @@ const createDefaultProducts = async ({ is_add_on: false, group: "", created_at: Date.now(), + version: 1, }), }); diff --git a/server/src/internal/api/features/featureApiRouter.ts b/server/src/internal/api/features/featureApiRouter.ts index 656093a01..aa726747a 100644 --- a/server/src/internal/api/features/featureApiRouter.ts +++ b/server/src/internal/api/features/featureApiRouter.ts @@ -22,8 +22,6 @@ import { validateMeteredConfig } from "@/internal/features/featureUtils.js"; export const featureApiRouter = express.Router(); - - export const validateFeature = (data: any) => { let featureType = data.type; @@ -85,11 +83,13 @@ featureApiRouter.post("", async (req: any, res) => { ...parsedFeature, }; - let insertedFeature = await FeatureService.insert({ + let insertedData = await FeatureService.insert({ sb: req.sb, data: feature, }); + let insertedFeature = + insertedData && insertedData.length > 0 ? insertedData[0] : null; res.status(200).json(insertedFeature); } catch (error) { handleRequestError({ req, error, res, action: "Create feature" }); diff --git a/server/src/internal/api/products/handleUpdateProduct.ts b/server/src/internal/api/products/handleUpdateProduct.ts index 33e725bf3..c7ba16129 100644 --- a/server/src/internal/api/products/handleUpdateProduct.ts +++ b/server/src/internal/api/products/handleUpdateProduct.ts @@ -1,4 +1,9 @@ -import { ErrCode, Organization, UpdateProductSchema } from "@autumn/shared"; +import { + ErrCode, + Organization, + RewardProgram, + UpdateProductSchema, +} from "@autumn/shared"; import { UpdateProduct } from "@autumn/shared"; import { Product } from "@autumn/shared"; import { SupabaseClient } from "@supabase/supabase-js"; @@ -15,12 +20,10 @@ import { handleVersionProduct, handleVersionProductV2, } from "./handleVersionProduct.js"; -import { - productsAreDifferent, - productsAreDifferent2, -} from "@/internal/products/productUtils.js"; +import { productsAreDifferent } from "@/internal/products/productUtils.js"; import { routeHandler } from "@/utils/routerUtils.js"; import { handleNewProductItems } from "@/internal/products/product-items/productItemInitUtils.js"; +import { RewardProgramService } from "@/internal/rewards/RewardProgramService.js"; export const handleUpdateProductDetails = async ({ newProduct, @@ -28,12 +31,14 @@ export const handleUpdateProductDetails = async ({ org, sb, cusProductExists, + rewardPrograms, }: { curProduct: Product; newProduct: UpdateProduct; org: Organization; sb: SupabaseClient; cusProductExists: boolean; + rewardPrograms: RewardProgram[]; }) => { // 1. Check if they're same // console.log("New product: ", newProduct); @@ -72,12 +77,23 @@ export const handleUpdateProductDetails = async ({ return; } - if (newProduct.id !== curProduct.id && customersOnAllVersions.length > 0) { - throw new RecaseError({ - message: "Cannot change product ID because it has existing customers", - code: ErrCode.ProductHasCustomers, - statusCode: 400, - }); + if (newProduct.id !== curProduct.id) { + if (customersOnAllVersions.length > 0) { + throw new RecaseError({ + message: "Cannot change product ID because it has existing customers", + code: ErrCode.ProductHasCustomers, + statusCode: 400, + }); + } + + if (rewardPrograms.length > 0) { + throw new RecaseError({ + message: + "Cannot change product ID because existing reward programs are linked to it", + code: ErrCode.ProductHasRewardPrograms, + statusCode: 400, + }); + } } console.log(`Updating product ${curProduct.id} (org: ${org.slug})`); @@ -146,6 +162,7 @@ export const handleUpdateProduct = async (req: any, res: any) => { newProduct: UpdateProductSchema.parse(req.body), org, cusProductExists, + rewardPrograms: [], }); let productHasChanged = productsAreDifferent({ @@ -232,7 +249,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => const { productId } = req.params; const { sb, orgId, env, logtail: logger } = req; - const [features, org, fullProduct] = await Promise.all([ + const [features, org, fullProduct, rewardPrograms] = await Promise.all([ FeatureService.getFromReq(req), OrgService.getFullOrg({ sb, @@ -244,6 +261,12 @@ export const handleUpdateProductV2 = async (req: any, res: any) => orgId, env, }), + RewardProgramService.getByProductId({ + sb, + productIds: [productId], + orgId, + env, + }), ]); if (!fullProduct) { @@ -255,6 +278,8 @@ export const handleUpdateProductV2 = async (req: any, res: any) => } // 1. Update product details + // Get reward programs using product id + const cusProductsCurVersion = await CusProductService.getByInternalProductId( sb, @@ -269,6 +294,7 @@ export const handleUpdateProductV2 = async (req: any, res: any) => newProduct: UpdateProductSchema.parse(req.body), org, cusProductExists, + rewardPrograms, }); // 1. Map to product items diff --git a/server/src/internal/api/products/handleVersionProduct.ts b/server/src/internal/api/products/handleVersionProduct.ts index 4d40571cf..eceeb02c2 100644 --- a/server/src/internal/api/products/handleVersionProduct.ts +++ b/server/src/internal/api/products/handleVersionProduct.ts @@ -164,6 +164,7 @@ export const handleVersionProductV2 = async ({ // Validate product items... validateProductItems({ newItems: items, + features, }); await ProductService.create({ sb, product: newProduct }); diff --git a/server/src/internal/api/products/productRouter.ts b/server/src/internal/api/products/productRouter.ts index e2193de7e..65adc7b54 100644 --- a/server/src/internal/api/products/productRouter.ts +++ b/server/src/internal/api/products/productRouter.ts @@ -1,29 +1,20 @@ import { ProductService } from "@/internal/products/ProductService.js"; import { Router } from "express"; -import { - AppEnv, - CreateFeatureSchema, - CreateProductSchema, -} from "@autumn/shared"; +import { CreateProductSchema } from "@autumn/shared"; import RecaseError, { handleRequestError } from "@/utils/errorUtils.js"; import { ErrCode } from "@/errors/errCodes.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { FeatureService } from "@/internal/features/FeatureService.js"; -import { initNewFeature } from "../features/featureApiRouter.js"; + import { checkStripeProductExists, constructProduct, - copyProduct, } from "@/internal/products/productUtils.js"; import { createStripePriceIFNotExist } from "@/external/stripe/createStripePrice/createStripePrice.js"; import { createStripeCli } from "@/external/stripe/utils.js"; -import { - handleUpdateProduct, - handleUpdateProductV2, -} from "./handleUpdateProduct.js"; +import { handleUpdateProductV2 } from "./handleUpdateProduct.js"; import { handleDeleteProduct } from "./handleDeleteProduct.js"; import { handleGetProduct } from "./handleGetProduct.js"; import { handleCopyProduct } from "./handlers/handleCopyProduct.js"; diff --git a/server/src/internal/customers/entitlements/getCusBalances.ts b/server/src/internal/customers/entitlements/getCusBalances.ts index 392c7790f..41a5c8377 100644 --- a/server/src/internal/customers/entitlements/getCusBalances.ts +++ b/server/src/internal/customers/entitlements/getCusBalances.ts @@ -52,7 +52,7 @@ export const getCusBalances = async ({ if (!data[key]) { data[key] = { feature_id: feature.id, - interval: ent.interval || undefined, + interval: isBoolean || unlimited ? null : ent.interval || undefined, unlimited: isBoolean ? undefined : unlimited, balance: isBoolean ? undefined : unlimited ? null : 0, total: isBoolean || unlimited ? undefined : 0, diff --git a/server/src/internal/features/FeatureService.ts b/server/src/internal/features/FeatureService.ts index 54623135a..cb3b4f2e3 100644 --- a/server/src/internal/features/FeatureService.ts +++ b/server/src/internal/features/FeatureService.ts @@ -29,7 +29,8 @@ export class FeatureService { .select("*") .eq("org_id", orgId) .eq("env", env) - .order("created_at", { ascending: false }).order("id"); + .order("created_at", { ascending: false }) + .order("id"); if (error) { throw error; @@ -138,8 +139,7 @@ export class FeatureService { let { data: insertedData, error } = await sb .from("features") .insert(data) - .select() - .single(); + .select(); if (error) { if (error.code === "23505") { diff --git a/server/src/internal/orgs/orgRouter.ts b/server/src/internal/orgs/orgRouter.ts index a67eccf09..2d37f1dba 100644 --- a/server/src/internal/orgs/orgRouter.ts +++ b/server/src/internal/orgs/orgRouter.ts @@ -46,7 +46,7 @@ orgRouter.post("/stripe", async (req: any, res) => { try { let { testApiKey, liveApiKey, successUrl, defaultCurrency } = req.body; - if (!testApiKey || !liveApiKey || !defaultCurrency || !successUrl) { + if (!testApiKey || !liveApiKey || !successUrl) { throw new RecaseError({ message: "Missing required fields", code: ErrCode.StripeKeyInvalid, diff --git a/server/src/internal/products/entitlements/entitlementUtils.ts b/server/src/internal/products/entitlements/entitlementUtils.ts index 387dc0598..9c5616785 100644 --- a/server/src/internal/products/entitlements/entitlementUtils.ts +++ b/server/src/internal/products/entitlements/entitlementUtils.ts @@ -120,7 +120,10 @@ export const validateEntitlement = ({ } if (parsedEnt.allowance_type == AllowanceType.Fixed) { - if (!notNullOrUndefined(parsedEnt.allowance) || parsedEnt.allowance! < 0) { + if ( + !notNullOrUndefined(parsedEnt.allowance) || + (typeof parsedEnt.allowance === "number" && parsedEnt.allowance < 0) + ) { throw new RecaseError({ code: ErrCode.InvalidEntitlement, message: `Allowance is required for feature ${parsedEnt.feature_id}`, @@ -156,9 +159,13 @@ export const validateEntitlement = ({ } let billingUnits = config.billing_units || 1; - let isMultipleOfBillingUnits = parsedEnt.allowance! % billingUnits === 0; + let isMultipleOfBillingUnits = + (parsedEnt.allowance! as number) % billingUnits === 0; - if (parsedEnt.allowance! < billingUnits || !isMultipleOfBillingUnits) { + if ( + (parsedEnt.allowance! as number) < billingUnits || + !isMultipleOfBillingUnits + ) { throw new RecaseError({ code: ErrCode.InvalidEntitlement, message: `Allowance for ${parsedEnt.feature_id} must be ≥ billing units and a multiple of billing units`, diff --git a/server/src/internal/products/product-items/itemIntervalUtils.ts b/server/src/internal/products/product-items/itemIntervalUtils.ts index 03b9d31b9..de3edcce3 100644 --- a/server/src/internal/products/product-items/itemIntervalUtils.ts +++ b/server/src/internal/products/product-items/itemIntervalUtils.ts @@ -14,10 +14,15 @@ export const billingToItemInterval = (billingInterval: BillingInterval) => { return billingInterval as unknown as ProductItemInterval; }; -export const entToItemInterval = (entInterval: EntInterval) => { +export const entToItemInterval = (entInterval?: EntInterval) => { + if (nullish(entInterval)) { + return null; + } + if (entInterval == EntInterval.Lifetime) { return null; } + return entInterval as unknown as ProductItemInterval; }; diff --git a/server/src/internal/products/product-items/mapFromItem.ts b/server/src/internal/products/product-items/mapFromItem.ts index 0ec5f9604..d79228ec7 100644 --- a/server/src/internal/products/product-items/mapFromItem.ts +++ b/server/src/internal/products/product-items/mapFromItem.ts @@ -80,6 +80,7 @@ export const toFeature = ({ internalProductId, isCustom, newVersion, + feature, }: { item: ProductItem; orgId: string; @@ -87,7 +88,10 @@ export const toFeature = ({ internalProductId: string; isCustom: boolean; newVersion?: boolean; + feature?: Feature; }) => { + let isBoolean = feature?.type == FeatureType.Boolean; + let ent: Entitlement = { id: item.entitlement_id || generateId("ent"), org_id: orgId, @@ -103,10 +107,11 @@ export const toFeature = ({ item.included_usage == Infinite ? AllowanceType.Unlimited : AllowanceType.Fixed, - interval: - item.reset_usage_on_billing === false - ? EntInterval.Lifetime - : (itemToEntInterval(item) as EntInterval), + interval: isBoolean + ? null + : item.reset_usage_on_billing === false + ? EntInterval.Lifetime + : (itemToEntInterval(item) as EntInterval), carry_from_previous: item.carry_over_usage || false, entity_feature_id: item.entity_feature_id, @@ -308,6 +313,7 @@ export const itemToPriceAndEnt = ({ internalProductId, isCustom, newVersion, + feature, }); if (!curEnt || newVersion) { diff --git a/server/src/internal/products/product-items/mapToItem.ts b/server/src/internal/products/product-items/mapToItem.ts index 44e31c297..6391c3c7c 100644 --- a/server/src/internal/products/product-items/mapToItem.ts +++ b/server/src/internal/products/product-items/mapToItem.ts @@ -37,6 +37,7 @@ export const toFeatureItem = ({ ent }: { ent: EntitlementWithFeature }) => { if (ent.feature.type == FeatureType.Boolean) { return { feature_id: ent.feature.id, + entitlement_id: ent.id, }; } diff --git a/server/src/internal/products/product-items/productItemInitUtils.ts b/server/src/internal/products/product-items/productItemInitUtils.ts index 2b78aab06..3b0d760b8 100644 --- a/server/src/internal/products/product-items/productItemInitUtils.ts +++ b/server/src/internal/products/product-items/productItemInitUtils.ts @@ -140,6 +140,7 @@ export const handleNewProductItems = async ({ // Validate product items... validateProductItems({ newItems, + features, }); let newPrices: Price[] = []; diff --git a/server/src/internal/products/product-items/productItemUtils.ts b/server/src/internal/products/product-items/productItemUtils.ts index 0bc400ff7..4cc41b9de 100644 --- a/server/src/internal/products/product-items/productItemUtils.ts +++ b/server/src/internal/products/product-items/productItemUtils.ts @@ -84,13 +84,13 @@ export const constructFeatureItem = ({ entitlement_id, }: { feature_id: string; - included_usage?: number | typeof Infinite; - interval: EntInterval; + included_usage?: number | string; + interval?: EntInterval; entitlement_id?: string; }) => { let item: ProductItem = { feature_id, - included_usage, + included_usage: included_usage as number, interval: entToItemInterval(interval), entitlement_id, }; diff --git a/server/src/internal/products/product-items/validateProductItems.ts b/server/src/internal/products/product-items/validateProductItems.ts index 1993a06ef..4a8dbecdb 100644 --- a/server/src/internal/products/product-items/validateProductItems.ts +++ b/server/src/internal/products/product-items/validateProductItems.ts @@ -7,14 +7,23 @@ import { ProductItemSchema, Infinite, ProductItemInterval, + Feature, + FeatureType, } from "@autumn/shared"; import { StatusCodes } from "http-status-codes"; import { isFeaturePriceItem } from "./productItemUtils.js"; import { notNullish, nullish } from "@/utils/genUtils.js"; import { isFeatureItem, isPriceItem } from "./getItemType.js"; import { itemToEntInterval } from "./itemIntervalUtils.js"; -const validateProductItem = ({ item }: { item: ProductItem }) => { +const validateProductItem = ({ + item, + features, +}: { + item: ProductItem; + features: Feature[]; +}) => { item = ProductItemSchema.parse(item); + // 1. Check if amount and tiers are not null if (notNullish(item.amount) && notNullish(item.tiers)) { throw new RecaseError({ @@ -101,12 +110,14 @@ const validateProductItem = ({ item }: { item: ProductItem }) => { }; export const validateProductItems = ({ newItems, + features, }: { newItems: ProductItem[]; + features: Feature[]; }) => { // 1. Check values for (let index = 0; index < newItems.length; index++) { - validateProductItem({ item: newItems[index] }); + validateProductItem({ item: newItems[index], features }); } for (let index = 0; index < newItems.length; index++) { diff --git a/server/src/internal/public/publicAttach.ts b/server/src/internal/public/publicAttach.ts index 1e3b645a0..cb4270ec8 100644 --- a/server/src/internal/public/publicAttach.ts +++ b/server/src/internal/public/publicAttach.ts @@ -14,19 +14,19 @@ export const publicAttachRouter = Router(); export const handlePublicAttach = async (req: any, res: any) => { { - const { customer_id, product_id, success_url, options } = req.body; - const orgId = req.minOrg.id; - const env = req.env; - - const sb = req.sb; - - const useCheckout = true; - const optionsListInput = options || []; - console.log("--------------------------------"); - console.log(`PUBLIC ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`); - try { // 1. Get full customer product data + const { customer_id, product_id, success_url, options } = req.body; + const orgId = req.minOrg.id; + const env = req.env; + + const sb = req.sb; + + const useCheckout = true; + const optionsListInput = options || []; + console.log("--------------------------------"); + console.log(`PUBLIC ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`); + const attachParams: AttachParams = await getFullCusProductData({ sb, customerId: customer_id, @@ -34,8 +34,7 @@ export const handlePublicAttach = async (req: any, res: any) => { orgId: orgId, env, customerData: {} as any, - pricesInput: [], - entsInput: [], + itemsInput: [], optionsListInput: optionsListInput, freeTrialInput: null, isCustom: false, diff --git a/server/src/internal/rewards/RewardProgramService.ts b/server/src/internal/rewards/RewardProgramService.ts index 1b16a34b5..16fabacc5 100644 --- a/server/src/internal/rewards/RewardProgramService.ts +++ b/server/src/internal/rewards/RewardProgramService.ts @@ -1,5 +1,5 @@ import RecaseError from "@/utils/errorUtils.js"; -import { ErrCode, RewardProgram } from "@autumn/shared"; +import { ErrCode, RewardProgram, RewardTriggerEvent } from "@autumn/shared"; import { ReferralCode } from "@shared/models/rewardModels/referralModels/referralModels.js"; export class RewardProgramService { @@ -76,6 +76,32 @@ export class RewardProgramService { return data; } + static async getByProductId({ + sb, + productIds, + orgId, + env, + }: { + sb: any; + productIds: string[]; + orgId: string; + env: string; + }) { + const { data, error } = await sb + .from("reward_programs") + .select("*") + .eq("org_id", orgId) + .eq("env", env) + .eq("when", RewardTriggerEvent.Checkout) + .contains("product_ids", productIds); + + if (error) { + throw error; + } + + return data; + } + static async create({ sb, data, diff --git a/server/src/websockets/initWs.ts b/server/src/websockets/initWs.ts index f5d14dcdd..562737216 100644 --- a/server/src/websockets/initWs.ts +++ b/server/src/websockets/initWs.ts @@ -4,10 +4,8 @@ import http from "http"; import { createSupabaseClient } from "@/external/supabaseUtils.js"; import { AppEnv, ErrCode } from "@autumn/shared"; import { OrgService } from "@/internal/orgs/OrgService.js"; -import { - getBalanceForFeature, - getCusBalances, -} from "@/internal/customers/entitlements/cusEntUtils.js"; +import { getBalanceForFeature } from "@/internal/customers/entitlements/cusEntUtils.js"; +import { getCusBalances } from "@/internal/customers/entitlements/getCusBalances.js"; export enum SbChannelEvent { BalanceUpdated = "balance_updated", @@ -145,52 +143,47 @@ export const initWs = (server: http.Server) => { }; const handleRealtimeBalances = async (ws: WebSocket, req: any, params: any) => { - try { - const { org, env, sb } = req; - - // 1. Get all customer balances - const balances = await getCusBalances({ - sb, - customerId: params.customer_id, - orgId: org.id, - env, - }); - - ws.send(JSON.stringify({ data: balances, error: null })); - - const channel = `${org.id}_${env}_${params.customer_id}`; - - sb.channel(channel) - .on( - "broadcast", - { event: SbChannelEvent.BalanceUpdated }, - async (payload: any) => { - const data = payload.payload; - console.log("Received balance update event from supabase:", data); - const newBalances = await getCusBalances({ - sb, - customerId: params.customer_id, - orgId: org.id, - env, - }); - ws.send( - JSON.stringify({ - data: newBalances, - error: null, - }) - ); - } - ) - .subscribe(); - } catch (error) { - console.log("Error getting customer balances", error); - ws.send( - JSON.stringify({ - data: null, - error: "Error getting customer balances", - }) - ); - } + // try { + // const { org, env, sb } = req; + // // 1. Get all customer balances + // const balances = await getCusBalances({ + // sb, + // customerId: params.customer_id, + // orgId: org.id, + // env, + // }); + // ws.send(JSON.stringify({ data: balances, error: null })); + // const channel = `${org.id}_${env}_${params.customer_id}`; + // sb.channel(channel) + // .on( + // "broadcast", + // { event: SbChannelEvent.BalanceUpdated }, + // async (payload: any) => { + // const data = payload.payload; + // console.log("Received balance update event from supabase:", data); + // const newBalances = await getCusBalances({ + // customerId: params.customer_id, + // orgId: org.id, + // env, + // }); + // ws.send( + // JSON.stringify({ + // data: newBalances, + // error: null, + // }) + // ); + // } + // ) + // .subscribe(); + // } catch (error) { + // console.log("Error getting customer balances", error); + // ws.send( + // JSON.stringify({ + // data: null, + // error: "Error getting customer balances", + // }) + // ); + // } }; const handleRealtimeBalance = async (ws: WebSocket, req: any, params: any) => { diff --git a/server/test.sh b/server/test.sh index 5e39ce52f..d6a0d20ca 100755 --- a/server/test.sh +++ b/server/test.sh @@ -5,17 +5,17 @@ MOCHA_CMD="npx mocha --parallel --timeout 10000000 --ignore tests/00_setup.ts" # TEST PARALLEL if [ "$1" == "basic-parallel" ]; then MOCHA_PARALLEL=true $MOCHA_SETUP && $MOCHA_CMD \ - tests/attach/**/*.ts \ - # tests/basic/*.ts \ - # tests/basic/entities/*.ts \ + tests/basic/*.ts \ + tests/basic/multi-feature/*.ts \ + tests/basic/entities/*.ts \ # tests/basic/referrals/*.ts \ - # tests/basic/multi-feature/*.ts \ + # tests/attach/**/*.ts \ elif [ "$1" == "advanced-parallel" ]; then MOCHA_PARALLEL=true \ $MOCHA_SETUP \ - && $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts'\ - # && $MOCHA_CMD 'tests/advanced/usage/*.ts'\ + && $MOCHA_CMD 'tests/advanced/usage/*.ts' \ + && $MOCHA_CMD 'tests/advanced/arrear_prorated/*.ts' 'tests/advanced/coupons/*.ts'\ # && $MOCHA_CMD 'tests/advanced/coupons/*.ts'\ diff --git a/server/tests/alex/utils.ts b/server/tests/alex/utils.ts index 06f5d0be7..cca366f74 100644 --- a/server/tests/alex/utils.ts +++ b/server/tests/alex/utils.ts @@ -91,12 +91,20 @@ export const checkFeatureHasCorrectBalance = async ({ // Get ent from cusRes const { entitlements: cusEnts }: any = cusRes; const { allowed, balanceObj }: any = entitledRes; + const cusEnt = cusEnts.find( (e: any) => e.feature_id === feature.id && e.interval == entitlement.interval ); - expect(cusEnt).to.exist; + try { + expect(cusEnt).to.exist; + } catch (error) { + console.log( + `Expected cus ent ${feature.id}, interval ${entitlement.interval} to exist` + ); + throw error; + } if (entitlement.allowance_type === AllowanceType.Unlimited) { // Cus ent diff --git a/server/tests/basic/07_downgrade.ts b/server/tests/basic/07_downgrade.ts index 1663f059e..6d01b2188 100644 --- a/server/tests/basic/07_downgrade.ts +++ b/server/tests/basic/07_downgrade.ts @@ -32,7 +32,7 @@ export const getCusProduct = async ( return data[0]; }; -describe.skip(`${chalk.yellowBright( +describe(`${chalk.yellowBright( "07_downgrade: testing downgrade (paid to paid)" )}`, () => { let customer: Customer; diff --git a/shared/errors/errCode.ts b/shared/errors/errCode.ts index e15ea50bd..56910f2fb 100644 --- a/shared/errors/errCode.ts +++ b/shared/errors/errCode.ts @@ -80,6 +80,7 @@ export const ErrCode = { DefaultProductNotAllowedPrice: "default_product_not_allowed_price", UpgradeFailed: "upgrade_failed", ProductAlreadyExists: "product_already_exists", + ProductHasRewardPrograms: "product_has_reward_programs", // Entitlements InvalidEntitlement: "invalid_entitlement", diff --git a/shared/models/productModels/entitlementModels.ts b/shared/models/productModels/entitlementModels.ts index 83705fed0..f257eeae3 100644 --- a/shared/models/productModels/entitlementModels.ts +++ b/shared/models/productModels/entitlementModels.ts @@ -33,7 +33,7 @@ export const CreateEntitlementSchema = z.object({ internal_feature_id: z.string(), feature_id: z.string(), allowance_type: z.nativeEnum(AllowanceType).nullish(), - allowance: z.union([z.number(), z.literal("unlimited")]).nullish(), + allowance: z.number().nullish(), interval: z.nativeEnum(EntInterval).nullish(), carry_from_previous: z.boolean().default(false), entity_feature_id: z.string().nullish(), diff --git a/vite/package.json b/vite/package.json index 391973a4d..12ba5332d 100644 --- a/vite/package.json +++ b/vite/package.json @@ -65,6 +65,7 @@ "@eslint/js": "^9.21.0", "@types/lodash": "^4.17.16", "@types/node": "^22.13.10", + "@types/prismjs": "^1.26.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", diff --git a/vite/src/app/layout.tsx b/vite/src/app/layout.tsx index 8a7294625..21d43461d 100644 --- a/vite/src/app/layout.tsx +++ b/vite/src/app/layout.tsx @@ -76,7 +76,12 @@ export function MainLayout() { } if (!org && !pathname.includes("/onboarding")) { - return ; + return ( + + ); } // 3. If user, but no org, redirect to onboarding diff --git a/vite/src/components/general/AdminHover.tsx b/vite/src/components/general/AdminHover.tsx index 68cc4a019..052e787bc 100644 --- a/vite/src/components/general/AdminHover.tsx +++ b/vite/src/components/general/AdminHover.tsx @@ -18,7 +18,8 @@ export const AdminHover = ({ let isAdmin = email === "johnyeocx@gmail.com" || email === "ayush@recaseai.com" || - email === "johnyeo10@gmail.com"; + email === "johnyeo10@gmail.com" || + email == "npmrundemo@gmail.com"; if (!isAdmin) return children; diff --git a/vite/src/components/ui/button.tsx b/vite/src/components/ui/button.tsx index 4bf5173a5..fa4ab2af8 100644 --- a/vite/src/components/ui/button.tsx +++ b/vite/src/components/ui/button.tsx @@ -101,7 +101,7 @@ const Button = React.forwardRef( props.onClick?.(e); }} > - {isLoading && } + {isLoading && } {startIcon && !isLoading && <>{startIcon}} {variant == "add" && } {children} diff --git a/vite/src/views/onboarding/OnboardingView.tsx b/vite/src/views/onboarding/OnboardingView.tsx index 56120a0e7..888cd93ba 100644 --- a/vite/src/views/onboarding/OnboardingView.tsx +++ b/vite/src/views/onboarding/OnboardingView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -49,8 +49,6 @@ import CheckAccessStep from "./onboarding-steps/05_CheckAccess"; function OnboardingView() { const env = useEnv(); - const { user } = useUser(); - // Started without org... const { organization: org } = useOrganization(); const [searchParams] = useSearchParams(); @@ -58,6 +56,7 @@ function OnboardingView() { let [apiKey, setApiKey] = useState(""); let [productId, setProductId] = useState(""); + const hasHandledOrg = useRef(false); const axiosInstance = useAxiosInstance({ env }); @@ -102,7 +101,9 @@ function OnboardingView() { }, [searchParams]); useEffect(() => { - if (org && !orgCreated) { + if (org && !orgCreated && !hasHandledOrg.current) { + // console.log("Gonna poll for org!"); + hasHandledOrg.current = true; pollForOrg(); } }, [org, orgCreated]); @@ -110,10 +111,9 @@ function OnboardingView() { return (
+ {orgCreated && ( <> - - {copied ? : } diff --git a/vite/src/views/onboarding/onboarding-steps/01_CreateOrg.tsx b/vite/src/views/onboarding/onboarding-steps/01_CreateOrg.tsx index fad9ea4e1..63e47e230 100644 --- a/vite/src/views/onboarding/onboarding-steps/01_CreateOrg.tsx +++ b/vite/src/views/onboarding/onboarding-steps/01_CreateOrg.tsx @@ -38,7 +38,7 @@ export const CreateOrgStep = ({ }); await setActive({ organization: org.id }); - await pollForOrg(); + // await pollForOrg(); toast.success(`Created your organization: ${org.name}`); setIsExploding(true); } catch (error: any) { @@ -74,9 +74,10 @@ export const CreateOrgStep = ({ } > {/*
*/} -
+
{ @@ -85,7 +86,7 @@ export const CreateOrgStep = ({ }} /> +
) : ( @@ -126,7 +148,7 @@ const CreateProductCard = ({ createProductLoading: boolean; }) => { return ( -
+
{/* } diff --git a/vite/src/views/onboarding/onboarding-steps/04_CreateSecretKey.tsx b/vite/src/views/onboarding/onboarding-steps/04_CreateSecretKey.tsx index 962365802..488afee3f 100644 --- a/vite/src/views/onboarding/onboarding-steps/04_CreateSecretKey.tsx +++ b/vite/src/views/onboarding/onboarding-steps/04_CreateSecretKey.tsx @@ -115,7 +115,7 @@ export const CreateSecretKey = ({ onClick={handleCreate} isLoading={loading} variant="gradientPrimary" - className="min-w-40" + className="min-w-44 w-44 max-w-44" > Create Secret Key diff --git a/vite/src/views/products/product/ProductView.tsx b/vite/src/views/products/product/ProductView.tsx index 592100fe9..4c40a312f 100644 --- a/vite/src/views/products/product/ProductView.tsx +++ b/vite/src/views/products/product/ProductView.tsx @@ -94,9 +94,6 @@ function ProductView({ env }: { env: AppEnv }) { return; } - console.log("Original product:", originalProduct.items); - console.log("Current product:", product.items); - const hasChanged = JSON.stringify(sortedProduct) !== JSON.stringify(originalProduct); setHasChanges(hasChanged); diff --git a/vite/src/views/products/product/prices/CreateFixedPrice.tsx b/vite/src/views/products/product/prices/CreateFixedPrice.tsx index 44b3e863b..b6204a227 100644 --- a/vite/src/views/products/product/prices/CreateFixedPrice.tsx +++ b/vite/src/views/products/product/prices/CreateFixedPrice.tsx @@ -45,7 +45,7 @@ function CreateFixedPrice({ className="h-full !text-lg min-w-36" /> - {org?.default_currency} + {org?.default_currency.toUpperCase() || "USD"}
diff --git a/vite/src/views/products/product/prices/PricingConfig.tsx b/vite/src/views/products/product/prices/PricingConfig.tsx index 44b183ff1..6133b0c75 100644 --- a/vite/src/views/products/product/prices/PricingConfig.tsx +++ b/vite/src/views/products/product/prices/PricingConfig.tsx @@ -99,7 +99,7 @@ export const PricingConfig = ({ Usage Based */} - + {/* */} {/* { ); useEffect(() => { - console.log(item, "item"); // if show price is changed to false, remove the "amount" from the item if (show.price) { setItem({ ...item, amount: null }); @@ -138,7 +138,7 @@ export const ProductItemConfig = () => {
); }; - -// const [originalEntitlement, _] = useState( -// entitlement || null -// ); -// const [showPerEntity, setShowPerEntity] = useState( -// entitlement?.entity_feature_id ? true : false -// ); - -// const [showPrice, setShowPrice] = useState( -// priceConfig.usage_tiers?.[0].amount > 0 || -// priceConfig.usage_tiers?.length > 1 || -// priceConfig.usage_tiers?.[0].to == -1 || // to prevent for a weird state with 0 price -// priceConfig.type == PriceType.Fixed || -// buttonType == "price" -// ); // for the add price button - -// const [showCycle, setShowCycle] = useState( -// entitlement && entitlement?.interval == EntInterval.Lifetime ? false : true -// ); - -// const [fields, setFields] = useState({ -// carry_from_previous: entitlement?.carry_from_previous || false, -// allowance_type: entitlement?.allowance_type || AllowanceType.Fixed, -// allowance: entitlement?.allowance || "", -// interval: entitlement?.interval || EntInterval.Month, -// entity_feature_id: entitlement?.entity_feature_id || "", -// }); - -// useEffect(() => { -// //translate pricing usage tiers into entitlement allowance config when saving new feature -// console.log(selectedFeature?.name, "priceConfig:", priceConfig); - -// let newAllowance: number | "unlimited"; -// if (fields.allowance_type == AllowanceType.Unlimited) { -// newAllowance = "unlimited"; -// } else if ( -// priceConfig.usage_tiers?.[0].amount == 0 && -// priceConfig.usage_tiers?.[0].to > 0 // to prevent for a weird bug with 0 price -// ) { -// newAllowance = Number(priceConfig.usage_tiers?.[0].to); -// if (isNaN(newAllowance)) { -// newAllowance = 0; -// } -// } else { -// newAllowance = 0; -// } - -// let newEntInterval; -// if (showPrice && showCycle) { -// newEntInterval = -// priceConfig.interval == BillingInterval.OneOff -// ? EntInterval.Lifetime -// : fields.interval; -// } else if (showCycle) { -// newEntInterval = fields.interval; -// } else { -// newEntInterval = EntInterval.Lifetime; -// } - -// if (selectedFeature) { -// const newEnt = CreateEntitlementSchema.parse({ -// internal_feature_id: selectedFeature.internal_id, -// feature_id: selectedFeature.id, -// feature: selectedFeature, -// ...fields, -// interval: newEntInterval, -// entity_feature_id: -// fields.entity_feature_id && showPerEntity -// ? fields.entity_feature_id -// : null, -// // allowance: fields.allowance ? Number(fields.allowance) : 0, -// allowance: newAllowance, -// }); - -// const originalEnt = originalEntitlement ? originalEntitlement : null; -// setEntitlement({ -// ...originalEnt, -// ...newEnt, -// feature: selectedFeature, -// } as EntitlementWithFeature); -// } else { -// setEntitlement(null); -// } -// }, [ -// selectedFeature, -// showCycle, -// showPrice, -// priceConfig, -// fields, -// originalEntitlement, -// showPerEntity, -// setEntitlement, -// ]); diff --git a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx index 2be8e609b..1efee60c9 100644 --- a/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx +++ b/vite/src/views/products/product/product-item/components/ConfigWithFeature.tsx @@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button"; import TieredPrice from "../TieredPrice"; import { useProductContext } from "../../ProductContext"; -import { Feature, FeatureType } from "@autumn/shared"; +import { Feature, FeatureType, Infinite } from "@autumn/shared"; import { itemIsUnlimited } from "@/utils/product/productItemUtils"; import { SelectCycle } from "./SelectCycle"; import MoreMenuButton from "../MoreMenuButton"; @@ -92,12 +92,14 @@ export const ConfigWithFeature = ({ { setItem({ @@ -108,7 +110,7 @@ export const ConfigWithFeature = ({ /> { setShow({ ...show, price: false }); @@ -120,7 +122,7 @@ export const ConfigWithFeature = ({ } else { setItem({ ...item, - included_usage: "unlimited", + included_usage: Infinite, }); } }} diff --git a/vite/src/views/products/product/product-item/components/SelectCycle.tsx b/vite/src/views/products/product/product-item/components/SelectCycle.tsx index 295913c2b..9d10e9a2c 100644 --- a/vite/src/views/products/product/product-item/components/SelectCycle.tsx +++ b/vite/src/views/products/product/product-item/components/SelectCycle.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/select"; import { TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip"; -import { BillingInterval, EntInterval } from "@autumn/shared"; +import { BillingInterval, EntInterval, Infinite } from "@autumn/shared"; import { InfoIcon, X } from "lucide-react"; import { useProductItemContext } from "../ProductItemContext"; import { cn } from "@/lib/utils"; @@ -85,7 +85,7 @@ export const SelectCycle = ({