diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index d140398a4..90f69bcf7 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center rounded-full border border-zinc-200 px-2.5 py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 dark:border-zinc-800 dark:focus:ring-zinc-300", @@ -17,13 +17,17 @@ const badgeVariants = cva( outline: "text-zinc-950 dark:text-zinc-50", purple: "bg-purple-50 text-purple-500", blue: "bg-blue-50 text-blue-500", + + green: "bg-green-50 text-green-500 border-green-200", + yellow: "bg-yellow-50 text-yellow-500 border-yellow-200", + red: "bg-red-50 text-red-500 border-red-200", }, }, defaultVariants: { variant: "default", }, } -) +); export interface BadgeProps extends React.HTMLAttributes, @@ -32,7 +36,7 @@ export interface BadgeProps function Badge({ className, variant, ...props }: BadgeProps) { return (
- ) + ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/frontend/src/utils/genUtils.ts b/frontend/src/utils/genUtils.ts index 927f73f92..75055a92d 100644 --- a/frontend/src/utils/genUtils.ts +++ b/frontend/src/utils/genUtils.ts @@ -1,6 +1,11 @@ import { AppEnv } from "@autumn/shared"; import KSUID from "ksuid"; +export const compareStatus = (statusA: string, statusB: string) => { + const statusOrder = ["scheduled", "active", "past_due", "expired"]; + return statusOrder.indexOf(statusA) - statusOrder.indexOf(statusB); +}; + export const generateId = (prefix: string) => { if (!prefix) { return KSUID.randomSync().string; diff --git a/frontend/src/views/customers/StatusBadge.tsx b/frontend/src/views/customers/StatusBadge.tsx new file mode 100644 index 000000000..aa982c6e9 --- /dev/null +++ b/frontend/src/views/customers/StatusBadge.tsx @@ -0,0 +1,13 @@ +import { Badge } from "@/components/ui/badge"; +import { CusProductStatus } from "@autumn/shared"; + +export const StatusBadge = ({ status }: { status: string }) => { + const statusToVariant = { + [CusProductStatus.Active]: "green", + [CusProductStatus.Scheduled]: "blue", + [CusProductStatus.PastDue]: "yellow", + [CusProductStatus.Expired]: "red", + }; + + return {status}; +}; diff --git a/frontend/src/views/customers/customer/CustomerProductList.tsx b/frontend/src/views/customers/customer/CustomerProductList.tsx index 8d22320a2..5ef0e10fc 100644 --- a/frontend/src/views/customers/customer/CustomerProductList.tsx +++ b/frontend/src/views/customers/customer/CustomerProductList.tsx @@ -7,10 +7,11 @@ import { TableRow, } from "@/components/ui/table"; import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils"; -import { navigateTo } from "@/utils/genUtils"; +import { compareStatus, navigateTo } from "@/utils/genUtils"; import { CusProduct } from "@autumn/shared"; import { useRouter } from "next/navigation"; import { useCustomerContext } from "./CustomerContext"; +import { StatusBadge } from "../StatusBadge"; export const CustomerProductList = ({ customer, @@ -20,7 +21,16 @@ export const CustomerProductList = ({ products: any; }) => { const router = useRouter(); - const {env} = useCustomerContext(); + const { env } = useCustomerContext(); + + const sortedProducts = customer.products.sort((a: any, b: any) => { + if (a.status !== b.status) { + return compareStatus(a.status, b.status); + } + + // return a.product.name.localeCompare(b.product.name); + return b.created_at - a.created_at; + }); return (
@@ -36,7 +46,7 @@ export const CustomerProductList = ({ - {customer.products.map((cusProduct: CusProduct) => { + {sortedProducts.map((cusProduct: CusProduct) => { return ( {cusProduct.product_id} - {cusProduct.status} + + + {formatUnixToDateTimeString(cusProduct.created_at)} diff --git a/frontend/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx b/frontend/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx index e119b544d..485c1eab6 100644 --- a/frontend/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx +++ b/frontend/src/views/customers/customer/entitlements/CustomerEntitlementsList.tsx @@ -14,6 +14,9 @@ import { } from "@autumn/shared"; import { useCustomerContext } from "../CustomerContext"; +import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils"; +import { compareStatus } from "@/utils/genUtils"; +import { StatusBadge } from "../../StatusBadge"; export const CustomerEntitlementsList = ({ customer }: { customer: any }) => { const { products } = useCustomerContext(); @@ -28,6 +31,30 @@ export const CustomerEntitlementsList = ({ customer }: { customer: any }) => { return product?.name; }; + const sortedEntitlements = customer.entitlements.sort((a: any, b: any) => { + const statusA = customer.products.find( + (cp: any) => cp.id === a.customer_product_id + )?.status; + + const statusB = customer.products.find( + (cp: any) => cp.id === b.customer_product_id + )?.status; + + if (statusA !== statusB) { + return compareStatus(statusA, statusB); + } + + const productA = customer.products.find( + (cp: any) => cp.id === a.customer_product_id + ); + + const productB = customer.products.find( + (cp: any) => cp.id === b.customer_product_id + ); + + return productA.product.name.localeCompare(productB.product.name); + }); + return (
@@ -36,11 +63,12 @@ export const CustomerEntitlementsList = ({ customer }: { customer: any }) => { ProductFeatureBalance - {/* */} + Next Reset + Status - {customer.entitlements.map((cusEnt: FullCustomerEntitlement) => { + {sortedEntitlements.map((cusEnt: FullCustomerEntitlement) => { const entitlement = cusEnt.entitlement; const allowanceType = entitlement.allowance_type; return ( @@ -58,11 +86,18 @@ export const CustomerEntitlementsList = ({ customer }: { customer: any }) => { ? "None" : cusEnt.balance} - {/* - - */} + + {formatUnixToDateTimeString(cusEnt.next_reset_at)} + + + p.id === cusEnt.customer_product_id + )?.status + } + /> + ); })} diff --git a/frontend/src/views/products/product/entitlements/EntitlementConfig.tsx b/frontend/src/views/products/product/entitlements/EntitlementConfig.tsx index 0f1af15a7..87744830a 100644 --- a/frontend/src/views/products/product/entitlements/EntitlementConfig.tsx +++ b/frontend/src/views/products/product/entitlements/EntitlementConfig.tsx @@ -48,7 +48,7 @@ export const EntitlementConfig = ({ const [fields, setFields] = useState({ allowance_type: entitlement?.allowance_type || AllowanceType.Fixed, allowance: entitlement?.allowance || 0, - interval: entitlement?.interval || EntInterval.Minute, + interval: entitlement?.interval || EntInterval.Month, }); useEffect(() => { diff --git a/server/run.sh b/server/run.sh index 623f5c7ff..07da60fee 100755 --- a/server/run.sh +++ b/server/run.sh @@ -1,3 +1,3 @@ # Run current file # npx tsx scripts/alex.ts -npx tsx scripts/copyOrg.ts +npx tsx scripts/alex_test.ts diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index 4f23f64d2..d177cf06c 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -6,7 +6,14 @@ import RecaseError, { handleRequestError, } from "@/utils/errorUtils.js"; import { generateId } from "@/utils/genUtils.js"; -import { CreateCustomerSchema, Customer, ProcessorType } from "@autumn/shared"; +import { + AppEnv, + CreateCustomer, + CreateCustomerSchema, + Customer, + CustomerResponseSchema, + ProcessorType, +} from "@autumn/shared"; import { Router } from "express"; import { StatusCodes } from "http-status-codes"; import { CusService } from "../../customers/CusService.js"; @@ -19,25 +26,88 @@ import { OrgService } from "@/internal/orgs/OrgService.js"; import { ProductService } from "@/internal/products/ProductService.js"; import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js"; import { EventService } from "../events/EventService.js"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { CustomerEntitlementService } from "@/internal/customers/entitlements/CusEntitlementService.js"; +import { FeatureService } from "@/internal/features/FeatureService.js"; export const cusRouter = Router(); +const createNewCustomer = async ({ + sb, + orgId, + env, + customer, + nextResetAt, +}: { + sb: SupabaseClient; + orgId: string; + env: AppEnv; + customer: CreateCustomer; + nextResetAt?: number; +}) => { + const org = await OrgService.getFullOrg({ + sb, + orgId, + }); + + const customerData: Customer = { + ...customer, + internal_id: generateId("cus"), + org_id: orgId, + created_at: Date.now(), + env, + }; + + let stripeCustomer: Stripe.Customer | undefined; + if (org.stripe_connected) { + stripeCustomer = await createStripeCustomer({ + org, + env, + customer: customerData, + }); + customerData.processor = { + type: ProcessorType.Stripe, + id: stripeCustomer?.id, + }; + } + + const newCustomer = await CusService.createCustomer({ + sb, + customer: customerData, + }); + + // Attach default product to customer + const defaultProds = await ProductService.getFullDefaultProduct({ + sb, + orgId, + env, + }); + + for (const product of defaultProds) { + await createFullCusProduct({ + sb, + attachParams: { + org, + customer: newCustomer, + product, + prices: product.prices, + entitlements: product.entitlements, + freeTrial: null, // TODO: Free trial not supported on default product yet + optionsList: [], + }, + nextResetAt, + }); + } + + return newCustomer; +}; + cusRouter.post("", async (req: any, res: any) => { try { const data = req.body; - const org = await OrgService.getFullOrg({ sb: req.sb, orgId: req.orgId }); // 1. Validate data - try { - CreateCustomerSchema.parse(data); - } catch (error: any) { - throw new RecaseError({ - message: "Invalid customer data, error: " + formatZodError(error), - code: ErrCode.InvalidCustomer, - statusCode: StatusCodes.BAD_REQUEST, - data: error, - }); - } + CreateCustomerSchema.parse(data); // 2. Check if customer ID already exists const existingCustomer = await CusService.getCustomer({ @@ -55,95 +125,19 @@ cusRouter.post("", async (req: any, res: any) => { }); } - // 3. Create stripe customer - let stripeCustomer: Stripe.Customer | undefined; - if (org.stripe_connected) { - stripeCustomer = await createStripeCustomer({ - org, - env: req.env, - customer: data, - }); - } - - // 4. Create customer in db - const newCustomer: Customer = { - ...data, - internal_id: generateId("cus"), - org_id: req.orgId, - created_at: Date.now(), + const createdCustomer = await createNewCustomer({ + sb: req.sb, + orgId: req.orgId, env: req.env, - - processor: stripeCustomer && { - type: ProcessorType.Stripe, - id: stripeCustomer.id, - }, - }; - - let createdCustomer: Customer; - try { - createdCustomer = await CusService.createCustomer({ - sb: req.sb, - customer: newCustomer, - }); - } catch (error: any) { - throw new RecaseError({ - message: "Error creating customer", - code: ErrCode.CreateCustomerFailed, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - data: error, - }); - } - - // 5. Attach default product to customer - try { - const defaultProduct = await ProductService.getFullDefaultProduct({ - sb: req.sb, - orgId: req.orgId, - env: req.env, - }); - - if (defaultProduct) { - const org = await OrgService.getFullOrg({ - sb: req.sb, - orgId: req.orgId, - }); - await createFullCusProduct({ - sb: req.sb, - attachParams: { - org, - customer: createdCustomer, - product: defaultProduct, - prices: defaultProduct.prices, - entitlements: defaultProduct.entitlements, - freeTrial: null, - optionsList: [], - }, - }); - } - } catch (error) { - throw new RecaseError({ - message: "Error attaching default product to customer", - code: ErrCode.AttachProductToCustomerFailed, - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - data: error, - }); - } - - res.status(200).json({ customer: createdCustomer, success: true }); - } catch (error: any) { - if (error instanceof RecaseError) { - error.print(); - res - .status(error.statusCode) - .json({ message: error.message, code: error.code }); - return; - } - - console.log("Unknown error creating customer", error); - res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - error: ErrorMessages.InternalError, - code: ErrCode.InternalError, + customer: data, }); + + res.status(200).json({ + customer: CustomerResponseSchema.parse(createdCustomer), + success: true, + }); + } catch (error: any) { + handleRequestError({ error, res, action: "create customer" }); } }); @@ -225,3 +219,142 @@ cusRouter.get("/:customer_id/events", async (req: any, res: any) => { handleRequestError({ error, res, action: "get customer events" }); } }); + +cusRouter.put("", async (req: any, res: any) => { + const { id, name, email, fingerprint, next_reset_at } = req.body; + + if (!id && !email) { + throw new RecaseError({ + message: "Customer ID or email is required", + code: ErrCode.InvalidCustomer, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + let existing = await CusService.getByIdOrEmail({ + sb: req.sb, + id, + email, + orgId: req.orgId, + env: req.env, + }); + + let newCustomer: Customer; + if (existing) { + newCustomer = await CusService.update({ + sb: req.sb, + internalCusId: existing.internal_id, + update: { id, name, email, fingerprint }, + }); + } else { + newCustomer = await createNewCustomer({ + sb: req.sb, + orgId: req.orgId, + env: req.env, + customer: { + id, + name, + email, + fingerprint, + }, + nextResetAt: next_reset_at, + }); + } + + res.status(200).json({ + customer: CustomerResponseSchema.parse(newCustomer), + success: true, + action: existing ? "update" : "create", + }); +}); + +cusRouter.post("/:customer_id/balances", async (req: any, res: any) => { + try { + const cusId = req.params.customer_id; + const { balances } = req.body; + + const customer = await CusService.getById({ + sb: req.sb, + id: cusId, + orgId: req.orgId, + env: req.env, + }); + + if (!customer) { + throw new RecaseError({ + message: `Customer ${cusId} not found`, + code: ErrCode.CustomerNotFound, + statusCode: StatusCodes.NOT_FOUND, + }); + } + + const features = await FeatureService.getFromReq(req); + const featuresToUpdate = features.filter((f) => + balances.map((b: any) => b.feature_id).includes(f.id) + ); + + const cusEnts = await CustomerEntitlementService.getActiveInFeatureIds({ + sb: req.sb, + internalCustomerId: customer.internal_id, + internalFeatureIds: featuresToUpdate.map((f) => f.internal_id), + }); + + // console.log("cusEnts", cusEnts); + for (const balance of balances) { + if (!balance.feature_id) { + throw new RecaseError({ + message: "Feature ID is required", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + if (typeof balance.balance !== "number") { + throw new RecaseError({ + message: "Balance must be a number", + code: ErrCode.InvalidRequest, + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + const feature = featuresToUpdate.find((f) => f.id === balance.feature_id); + // const cusEntToUpdate = cusEnts.find((e) => e.feature_id === feature.id); + + // How much to update + let curBalance = 0; + let newBalance = balance.balance; + for (const cusEnt of cusEnts) { + if (cusEnt.internal_feature_id === feature.internal_id) { + curBalance += cusEnt.balance; + } + } + + let updateAmount = newBalance - curBalance; + + for (const cusEnt of cusEnts) { + if (updateAmount == 0) break; + if (cusEnt.internal_feature_id === feature.internal_id) { + if (cusEnt.balance + updateAmount < 0) { + updateAmount += cusEnt.balance; + newBalance = 0; + } else { + newBalance = cusEnt.balance + updateAmount; + updateAmount = 0; + } + + await CustomerEntitlementService.update({ + sb: req.sb, + id: cusEnt.id, + updates: { + balance: newBalance, + }, + }); + } + } + } + + res.status(200).json({ success: true }); + } catch (error) { + handleRequestError({ error, res, action: "update customer balances" }); + } +}); diff --git a/server/src/internal/customers/CusService.ts b/server/src/internal/customers/CusService.ts index 38b71befc..1a5681d83 100644 --- a/server/src/internal/customers/CusService.ts +++ b/server/src/internal/customers/CusService.ts @@ -5,6 +5,109 @@ import { ErrCode } from "@/errors/errCodes.js"; import { StatusCodes } from "http-status-codes"; export class CusService { + static async getById({ + sb, + id, + orgId, + env, + }: { + sb: SupabaseClient; + id: string; + orgId: string; + env: AppEnv; + }) { + const { data, error } = await sb + .from("customers") + .select() + .eq("id", id) + .eq("org_id", orgId) + .eq("env", env); + + if (error) { + throw new RecaseError({ + code: ErrCode.InternalError, + message: "Failed to get customer by ID", + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + data: error, + }); + } + + if (data.length === 0) { + return null; + } + + return data[0]; + } + + static async getByEmail({ + sb, + email, + orgId, + env, + }: { + sb: SupabaseClient; + email: string; + orgId: string; + env: AppEnv; + }) { + const { data, error } = await sb + .from("customers") + .select() + .eq("email", email); + + if (error) { + throw new RecaseError({ + code: ErrCode.InternalError, + message: "Failed to get customer by email", + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + data: error, + }); + } + + if (data.length === 0) { + return null; + } else if (data.length > 2) { + throw new RecaseError({ + code: ErrCode.InternalError, + message: "Multiple customers found with the same email", + statusCode: StatusCodes.BAD_REQUEST, + }); + } + + return data[0]; + } + + static async getByIdOrEmail({ + sb, + id, + email, + orgId, + env, + }: { + sb: SupabaseClient; + id: string; + email: string; + orgId: string; + env: AppEnv; + }) { + const { data, error } = await sb + .from("customers") + .select() + .or(`id.eq.${id},email.eq.${email}`) + .eq("org_id", orgId) + .eq("env", env) + .single(); + + if (error) { + if (error.code === "PGRST116") { + return null; + } + throw error; + } + + return data; + } + static async getByInternalId({ sb, internalId, @@ -199,9 +302,18 @@ export class CusService { const { data, error } = await sb .from("customers") .update(update) - .eq("internal_id", internalCusId); + .eq("internal_id", internalCusId) + .select() + .single(); if (error) { + if (error.code == "2305") { + throw new RecaseError({ + message: `Customer ${internalCusId} already exists`, + code: ErrCode.DuplicateCustomerId, + statusCode: StatusCodes.BAD_REQUEST, + }); + } throw new RecaseError({ message: `Error updating customer...please try again later.`, code: ErrCode.InternalError, diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 99b0f1900..9c221c161 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -72,6 +72,10 @@ export const initCusEntitlement = ({ // 3. Define expires at (TODO next time...) let isBooleanFeature = entitlement.feature.type === FeatureType.Boolean; + let nextResetNull = + isBooleanFeature || + entitlement.allowance_type === AllowanceType.Unlimited || + entitlement.interval == EntInterval.Lifetime; return { id: generateId("cus_ent"), @@ -91,9 +95,7 @@ export const initCusEntitlement = ({ : entitlement.allowance_type === AllowanceType.Unlimited, balance: isBooleanFeature ? null : balance, usage_allowed: isBooleanFeature ? null : false, - next_reset_at: isBooleanFeature - ? null - : nextResetAt || nextResetAtCalculated, + next_reset_at: nextResetNull ? null : nextResetAt || nextResetAtCalculated, }; }; diff --git a/server/src/internal/customers/entitlements/CusEntitlementService.ts b/server/src/internal/customers/entitlements/CusEntitlementService.ts index 364e42d5a..4e2f904c8 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 { AppEnv, CustomerEntitlement, ErrCode } from "@autumn/shared"; +import { CustomerEntitlement, ErrCode } from "@autumn/shared"; import { SupabaseClient } from "@supabase/supabase-js"; import { StatusCodes } from "http-status-codes"; @@ -132,7 +132,8 @@ export class CustomerEntitlementService { ) .eq("internal_customer_id", internalCustomerId) .in("internal_feature_id", internalFeatureIds) - .eq("customer_product.status", "active"); + .eq("customer_product.status", "active") + .order("created_at", { ascending: true }); if (error) { throw new RecaseError({ diff --git a/server/src/internal/products/ProductService.ts b/server/src/internal/products/ProductService.ts index 8537cca7a..cae0af266 100644 --- a/server/src/internal/products/ProductService.ts +++ b/server/src/internal/products/ProductService.ts @@ -32,19 +32,22 @@ export class ProductService { }) { const { data, error } = await sb .from("products") - .select("*, prices(*), entitlements(*, feature:features(*))") + .select( + "*, prices(*), entitlements(*, feature:features(*)), free_trial:free_trials(*)" + ) .eq("org_id", orgId) .eq("env", env) - .eq("is_default", true) - .single(); + .eq("is_default", true); if (error) { - if (error.code === "PGRST116") { - return null; - } throw error; } + for (const product of data) { + product.free_trial = + product.free_trial.length > 0 ? product.free_trial[0] : null; + } + return data; } diff --git a/shared/models/cusModels/cusModels.ts b/shared/models/cusModels/cusModels.ts index d6275d53e..f254672fb 100644 --- a/shared/models/cusModels/cusModels.ts +++ b/shared/models/cusModels/cusModels.ts @@ -2,26 +2,24 @@ import { z } from "zod"; import { AppEnv } from "../genModels.js"; export const CustomerSchema = z.object({ + id: z.string(), // given by user + name: z.string().nullish(), + email: z.string().nullish(), + fingerprint: z.string().nullish(), + + // Internal internal_id: z.string(), org_id: z.string(), created_at: z.number(), env: z.nativeEnum(AppEnv), processor: z.any(), - - id: z.string(), // given by user - - name: z.string().nullish(), - email: z.string().nullish(), - fingerprint: z.string().nullish(), }); -export const CreateCustomerSchema = CustomerSchema.omit({ - internal_id: true, - org_id: true, - created_at: true, - env: true, - processor: true, - fingerprint: true, +export const CreateCustomerSchema = z.object({ + id: z.string(), + name: z.string().default(""), + email: z.string().default(""), + fingerprint: z.string().nullish(), }); export const CustomerDataSchema = z.object({ @@ -30,5 +28,15 @@ export const CustomerDataSchema = z.object({ fingerprint: z.string().nullish(), }); +export const CustomerResponseSchema = CustomerSchema.omit({ + // created_at: true, + // env: true, + internal_id: true, + org_id: true, + processor: true, +}); + export type Customer = z.infer; export type CustomerData = z.infer; +export type CustomerResponse = z.infer; +export type CreateCustomer = z.infer;