created PUT customer and update customer balances

This commit is contained in:
John Yeo
2025-01-29 15:04:31 +00:00
parent d94053b2f2
commit 058643588f
13 changed files with 471 additions and 143 deletions

View File

@@ -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<HTMLDivElement>,
@@ -32,7 +36,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -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;

View File

@@ -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 <Badge variant={statusToVariant[status]}>{status}</Badge>;
};

View File

@@ -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 (
<div>
@@ -36,7 +46,7 @@ export const CustomerProductList = ({
</TableRow>
</TableHeader>
<TableBody>
{customer.products.map((cusProduct: CusProduct) => {
{sortedProducts.map((cusProduct: CusProduct) => {
return (
<TableRow
key={cusProduct.id}
@@ -55,7 +65,9 @@ export const CustomerProductList = ({
<TableCell className="max-w-[100px] overflow-hidden text-ellipsis">
{cusProduct.product_id}
</TableCell>
<TableCell>{cusProduct.status}</TableCell>
<TableCell>
<StatusBadge status={cusProduct.status} />
</TableCell>
<TableCell>
{formatUnixToDateTimeString(cusProduct.created_at)}
</TableCell>

View File

@@ -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 (
<div>
<Table className="p-2">
@@ -36,11 +63,12 @@ export const CustomerEntitlementsList = ({ customer }: { customer: any }) => {
<TableHead className="w-[150px]">Product</TableHead>
<TableHead className="w-[150px]">Feature</TableHead>
<TableHead className="">Balance</TableHead>
{/* <TableHead className="w-[100px]"></TableHead> */}
<TableHead className="">Next Reset</TableHead>
<TableHead className="">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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}
</TableCell>
{/* <TableCell className="flex justify-end">
<CustomerEntitlementToolbar
entitlement={cusEnt.entitlement}
/>
</TableCell> */}
<TableCell>
{formatUnixToDateTimeString(cusEnt.next_reset_at)}
</TableCell>
<TableCell>
<StatusBadge
status={
customer.products.find(
(p: any) => p.id === cusEnt.customer_product_id
)?.status
}
/>
</TableCell>
</TableRow>
);
})}

View File

@@ -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(() => {

View File

@@ -1,3 +1,3 @@
# Run current file
# npx tsx scripts/alex.ts
npx tsx scripts/copyOrg.ts
npx tsx scripts/alex_test.ts

View File

@@ -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" });
}
});

View File

@@ -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,

View File

@@ -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,
};
};

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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<typeof CustomerSchema>;
export type CustomerData = z.infer<typeof CustomerDataSchema>;
export type CustomerResponse = z.infer<typeof CustomerResponseSchema>;
export type CreateCustomer = z.infer<typeof CreateCustomerSchema>;