added events to frontend + finished invoice below threshold

This commit is contained in:
John Yeo
2025-01-23 15:54:40 +00:00
parent 0dda081dc9
commit 009e2fda00
16 changed files with 198 additions and 54 deletions

View File

@@ -6,6 +6,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { formatUnixToDateTimeString } from "@/utils/formatUtils/formatDateUtils";
import { CusProduct } from "@autumn/shared";
import { useRouter } from "next/navigation";
@@ -25,6 +26,10 @@ export const CustomerProductList = ({
<TableRow className="">
<TableHead className="w-[150px]">Name</TableHead>
<TableHead className="">Product ID</TableHead>
<TableHead className="">Status</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="">Ended At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -42,7 +47,18 @@ export const CustomerProductList = ({
<TableCell>
{products.find((p) => p.id === cusProduct.product_id)?.name}
</TableCell>
<TableCell>{cusProduct.product_id}</TableCell>
<TableCell className="max-w-[100px] overflow-hidden text-ellipsis">
{cusProduct.product_id}
</TableCell>
<TableCell>{cusProduct.status}</TableCell>
<TableCell>
{formatUnixToDateTimeString(cusProduct.created_at)}
</TableCell>
<TableCell>
{cusProduct.ended_at
? formatUnixToDateTimeString(cusProduct.ended_at)
: ""}
</TableCell>
</TableRow>
);
})}

View File

@@ -29,7 +29,10 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSquareUpRight } from "@fortawesome/pro-duotone-svg-icons";
import { CustomerProductList } from "./CustomerProductList";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import {
formatUnixToDateTime,
formatUnixToDateTimeString,
} from "@/utils/formatUtils/formatDateUtils";
import { ManageEntitlements } from "./entitlements/ManageEntitlements";
import { CustomerEntitlementsList } from "./entitlements/CustomerEntitlementsList";
import { navigateTo } from "@/utils/genUtils";
@@ -47,12 +50,23 @@ export default function CustomerView({
env,
});
const {
data: eventsData,
isLoading: eventsLoading,
error: eventsError,
} = useAxiosSWR({
url: `/v1/customers/${customer_id}/events`,
env,
});
if (error) {
router.push("/customers");
}
if (isLoading) return <LoadingScreen />;
const { customer, products, invoices } = data;
const { events } = eventsData;
return (
<CustomerContext.Provider value={{ customer, products, env }}>
@@ -139,6 +153,7 @@ export default function CustomerView({
<TableHead className="w-[150px]">Products</TableHead>
<TableHead className="">URL</TableHead>
<TableHead className="">Created At</TableHead>
<TableHead className="">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -154,7 +169,7 @@ export default function CustomerView({
</TableCell>
<TableCell className="max-w-[400px] truncate">
<a
href={invoice.processor.hosted_invoice_url}
href={invoice.hosted_invoice_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-lime-500"
@@ -170,10 +185,21 @@ export default function CustomerView({
{formatUnixToDateTime(invoice.created_at).time}{" "}
</span>
</TableCell>
<TableCell>{invoice.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-t2 font-medium text-md">Events</p>
<div className="flex flex-col gap-1">
{events.map((event) => (
<div key={event.id} className="flex gap-1">
<p>- {formatUnixToDateTimeString(event.timestamp)}</p>
<p>- {event.event_name}</p>
</div>
))}
</div>
</div>
{/* customer details */}
<div className="flex flex-col gap-4 text-t2 text-sm w-full max-w-[400px] h-fit">

View File

@@ -24,14 +24,9 @@ export const handleCheckoutSessionCompleted = async ({
checkoutSession: Stripe.Checkout.Session;
env: AppEnv;
}) => {
console.log(
"Stripe webhook, handlingcheckout.completed, autumn metadata:",
checkoutSession.metadata?.autumn_metadata_id
);
const metadata = await getMetadataFromCheckoutSession(checkoutSession, sb);
if (!metadata) {
console.log("Metadata not found");
console.log("checkout.completed: metadata not found, skipping");
return;
}
@@ -46,15 +41,20 @@ export const handleCheckoutSessionCompleted = async ({
} = metadata.data;
if (metadataOrg.id != org.id) {
console.log("Org doesn't match, skipping");
console.log("checkout.completed: org doesn't match, skipping");
return;
}
if (metadataEnv != env) {
console.log("Environments don't match, skipping");
console.log("checkout.completed: environments don't match, skipping");
return;
}
console.log(
"Handling checkout.completed, autumn metadata:",
checkoutSession.metadata?.autumn_metadata_id
);
await CusProductService.expireCurrentProduct({
sb,
internalCustomerId: customer.internal_id,

View File

@@ -23,6 +23,7 @@ export const handleInvoicePaid = async ({
if (!cusProduct) {
return;
}
let existingInvoice = await InvoiceService.getInvoiceByStripeId({
sb,
stripeInvoiceId: invoice.id,

View File

@@ -105,13 +105,13 @@ export const handleSubscriptionUpdated = async ({
org: Organization;
subscription: any;
}) => {
console.log("Subscription updated:", {
id: subscription.id,
status: subscription.status,
customer: subscription.customer,
canceled_at: subscription.canceled_at,
schedule_id: subscription.subscription_schedule,
});
// console.log("Subscription updated:", {
// id: subscription.id,
// status: subscription.status,
// customer: subscription.customer,
// canceled_at: subscription.canceled_at,
// schedule_id: subscription.subscription_schedule,
// });
// 1. Undo stripe sub cancellation if it was cancelled
if (subscription.canceled_at !== null) {

View File

@@ -1,7 +1,10 @@
import { ErrCode } from "@/errors/errCodes.js";
import { ErrorMessages } from "@/errors/errMessages.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import RecaseError, {
formatZodError,
handleRequestError,
} from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { CreateCustomerSchema, Customer, ProcessorType } from "@autumn/shared";
import { Router } from "express";
@@ -15,6 +18,7 @@ import Stripe from "stripe";
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";
export const cusRouter = Router();
@@ -191,4 +195,18 @@ cusRouter.delete("/:customerId", async (req: any, res: any) => {
}
});
// cusRouter.use("/:customer_id/products", cusProductApiRouter);
cusRouter.get("/:customer_id/events", async (req: any, res: any) => {
const customerId = req.params.customer_id;
try {
const events = await EventService.getByCustomerId({
sb: req.sb,
customerId,
org: req.org,
env: req.env,
});
res.status(200).json({ events });
} catch (error) {
handleRequestError({ error, res, action: "get customer events" });
}
});

View File

@@ -107,8 +107,8 @@ const handleExistingProduct = async ({
internalCustomerId: customer.internal_id,
});
// 2. Check if customer already has product
if (existingCusProduct?.product_id === product.id && !product.is_add_on) {
// 2. Don't allow customer to get multiple of the same product
if (existingCusProduct?.product_id === product.id) {
// If there's a future product, delete, else
const deletedCusProduct = await CusProductService.deleteFutureProduct({
sb,

View File

@@ -1,5 +1,5 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { ErrCode, Event } from "@autumn/shared";
import { ErrCode, Event, Organization } from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
import { StatusCodes } from "http-status-codes";
@@ -28,4 +28,38 @@ export class EventService {
return data;
}
static async getByCustomerId({
sb,
customerId,
org,
env,
limit = 10,
}: {
sb: SupabaseClient;
customerId: string;
org: Organization;
env: string;
limit?: number;
}) {
const { data, error } = await sb
.from("events")
.select("*")
.eq("customer_id", customerId)
.eq("org_id", org.id)
.eq("env", env)
.order("timestamp", { ascending: false })
.limit(limit);
if (error) {
throw new RecaseError({
message: "Failed to get events",
code: ErrCode.InternalError,
data: error,
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
});
}
return data;
}
}

View File

@@ -57,6 +57,7 @@ const getEventAndCustomer = async (req: any) => {
org_id: orgId,
env: env,
timestamp: Date.now(),
properties: {},
...body,
};
} catch (error: any) {
@@ -216,6 +217,8 @@ eventsRouter.post("", async (req: any, res: any) => {
);
// console.log("Queued update balance task...");
} else {
console.log("No affected features found");
}
res.status(200).json({ success: true, event_id: event.id });

View File

@@ -17,7 +17,7 @@ import { ErrCode } from "@/errors/errCodes.js";
import { StatusCodes } from "http-status-codes";
import RecaseError from "@/utils/errorUtils.js";
import { getEntOptions } from "@/internal/prices/priceUtils.js";
import { PriceOptions, CustomerPrice } from "@autumn/shared";
import { CustomerPrice } from "@autumn/shared";
import { CusProductService } from "../products/CusProductService.js";
export const initCusEntitlement = ({
@@ -26,12 +26,14 @@ export const initCusEntitlement = ({
cusProductId,
options,
nextResetAt,
billLaterOnly = false,
}: {
entitlement: EntitlementWithFeature;
customer: Customer;
cusProductId: string;
options?: FeatureOptions;
nextResetAt?: number;
billLaterOnly?: boolean;
}) => {
const feature: Feature = entitlement.feature;
@@ -39,7 +41,7 @@ export const initCusEntitlement = ({
let allowance = entitlement.allowance || 0;
let quantity = options?.quantity || 1;
let balance = allowance * quantity;
let balance = billLaterOnly ? 0 : allowance * quantity;
// 2. Define reset interval (interval at which balance is reset to quantity * allowance)
let reset_interval = entitlement.interval as EntInterval;
@@ -224,6 +226,7 @@ export const createFullCusProduct = async ({
subscriptionId,
subscriptionScheduleId,
nextResetAt,
billLaterOnly = false,
}: {
sb: SupabaseClient;
customer: Customer;
@@ -235,6 +238,7 @@ export const createFullCusProduct = async ({
subscriptionId?: string;
subscriptionScheduleId?: string;
nextResetAt?: number;
billLaterOnly?: boolean;
}) => {
if (!product.is_add_on) {
await expireOrDeleteCusProduct({
@@ -258,6 +262,7 @@ export const createFullCusProduct = async ({
cusProductId: cusProdId,
options: options || undefined,
nextResetAt,
billLaterOnly,
});
cusEnts.push(cusEnt);

View File

@@ -180,6 +180,7 @@ export const handleAddProduct = async ({
entitlements,
optionsList,
subscriptionId: undefined,
billLaterOnly: true,
});
console.log("Successfully created full cus product");

View File

@@ -1,5 +1,5 @@
import { SupabaseClient } from "@supabase/supabase-js";
import { Invoice, ProcessorType } from "@autumn/shared";
import { Invoice, InvoiceStatus, ProcessorType } from "@autumn/shared";
import Stripe from "stripe";
import { generateId } from "@/utils/genUtils.js";
import RecaseError from "@/utils/errorUtils.js";
@@ -48,7 +48,7 @@ export class InvoiceService {
const { data, error } = await sb
.from("invoices")
.select("*")
.eq("processor->>id", stripeInvoiceId)
.eq("stripe_id", stripeInvoiceId)
.single();
if (error) {
@@ -65,22 +65,22 @@ export class InvoiceService {
stripeInvoice,
internalCustomerId,
productIds,
status,
}: {
sb: SupabaseClient;
stripeInvoice: Stripe.Invoice;
internalCustomerId: string;
productIds: string[];
status?: InvoiceStatus | null;
}) {
const invoice: Invoice = {
id: generateId("inv"),
internal_customer_id: internalCustomerId,
product_ids: productIds,
created_at: stripeInvoice.created * 1000,
processor: {
id: stripeInvoice.id,
type: ProcessorType.Stripe,
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
},
stripe_id: stripeInvoice.id,
hosted_invoice_url: stripeInvoice.hosted_invoice_url || null,
status: status || (stripeInvoice.status as InvoiceStatus | null),
};
// Check if invoice already exists
@@ -91,10 +91,13 @@ export class InvoiceService {
});
if (existingInvoice) {
console.log("Invoice already exists");
return;
}
const { error } = await sb.from("invoices").insert(invoice);
const { error } = await sb.from("invoices").upsert(invoice, {
onConflict: "stripe_id",
});
if (error) {
console.log("Failed to create invoice from stripe", error);

View File

@@ -11,6 +11,7 @@ import {
FullCusProduct,
CusProductStatus,
CustomerEntitlement,
InvoiceStatus,
} from "@autumn/shared";
import dotenv from "dotenv";
@@ -79,6 +80,7 @@ const payForInvoice = async ({
});
if (!paymentMethod) {
console.log(" ❌ No payment method found");
return false;
}
@@ -87,7 +89,9 @@ const payForInvoice = async ({
payment_method: paymentMethod as string,
});
} catch (error: any) {
console.log("Failed to pay invoice: " + error?.message || error);
console.log(
" ❌ Stripe error: Failed to pay invoice: " + error?.message || error
);
return false;
}
@@ -96,24 +100,39 @@ const payForInvoice = async ({
const handleInvoicePaymentFailure = async ({
sb,
stripeCli,
fullCusProduct,
fullCusPrice,
finalizedInvoice,
}: {
sb: SupabaseClient;
stripeCli: Stripe;
fullCusProduct: FullCusProduct;
fullCusPrice: FullCustomerPrice;
finalizedInvoice: Stripe.Invoice;
}) => {
// 1. Update customer product
console.log(
"Payment failed, updating customer product status to past due..."
);
console.log(" Handling invoice payment failure...");
// Void invoice
await stripeCli.invoices.voidInvoice(finalizedInvoice.id);
console.log(" a. Stripe invoice voided");
await InvoiceService.createInvoiceFromStripe({
sb,
stripeInvoice: finalizedInvoice,
internalCustomerId: fullCusProduct.internal_customer_id,
productIds: [fullCusProduct.product.id],
status: InvoiceStatus.Void,
});
console.log(" b. Invoice inserted into db");
await CusProductService.update({
sb,
cusProductId: fullCusProduct.id,
updates: {
status: CusProductStatus.PastDue,
status: CusProductStatus.Expired,
ended_at: Date.now(),
processor: {
...fullCusProduct.processor!,
last_invoice_id: finalizedInvoice.id,
@@ -121,7 +140,7 @@ const handleInvoicePaymentFailure = async ({
},
});
console.log("Customer product updated successfully");
console.log(" c. Expired customer product");
};
const invoiceCustomer = async ({
@@ -159,7 +178,7 @@ const invoiceCustomer = async ({
});
// 1. Create invoice
console.log("1. Creating invoice...");
console.log(" a. Creating invoice...");
const finalizedInvoice = await createBelowThresholdInvoice({
stripeCli,
customer,
@@ -168,7 +187,7 @@ const invoiceCustomer = async ({
});
// 2. Pay for invoice
console.log("2. Paying for invoice...");
console.log(" b. Paying for invoice...");
const paid = await payForInvoice({
fullOrg,
env: customer.env as AppEnv,
@@ -178,9 +197,10 @@ const invoiceCustomer = async ({
});
if (!paid) {
console.log("Failed to pay for invoice");
console.log("Failed to pay for invoice");
await handleInvoicePaymentFailure({
sb,
stripeCli,
fullCusProduct,
fullCusPrice,
finalizedInvoice,
@@ -188,16 +208,18 @@ const invoiceCustomer = async ({
return;
}
console.log("3. Inserting invoice into db...");
// 3. Insert invoice into db
console.log(" c. Inserting invoice into db...");
await InvoiceService.createInvoiceFromStripe({
sb,
stripeInvoice: finalizedInvoice,
internalCustomerId: customer.internal_id,
productIds: [fullCusProduct.product.id],
status: InvoiceStatus.Paid,
});
// 4. Update customer product
console.log("4. Updating customer product...");
console.log(" d. Updating customer product...");
await CusProductService.update({
sb,
cusProductId: fullCusProduct.id,
@@ -210,11 +232,11 @@ const invoiceCustomer = async ({
});
// 5. Update feature balance
console.log("5. Updating feature balance...");
console.log(" e. Updating feature balance...");
const newBalance = cusEnt.balance! + cusEnt.entitlement.allowance!;
console.log(
"Current balance:",
" - Current balance:",
cusEnt.balance,
"| Update amount:",
cusEnt.entitlement.allowance,
@@ -314,7 +336,7 @@ export const handleBelowThresholdInvoicing = async ({
});
console.log(
` - Feature balance: ${balance}, threshold: ${threshold}, below: ${below}`
` - Current balance: ${balance}, threshold: ${threshold}, below: ${below}`
);
if (!below) {

View File

@@ -54,7 +54,7 @@ export const runUpdateBalanceTask = async (payload: any) => {
const { customer, features } = payload;
console.log("--------------------------------");
console.log("Inside runUpdateBalanceTask...");
console.log("Inside updateBalanceTask...");
console.log("1. Updating customer balance...");
const cusEnts: any = await updateCustomerBalance({
@@ -63,8 +63,13 @@ export const runUpdateBalanceTask = async (payload: any) => {
features,
});
// 2. Check if there's below threshold price
if (!cusEnts || cusEnts.length === 0) {
console.log("✅ No customer entitlements found, skipping");
return;
}
console.log(" ✅ Customer balance updated");
// 2. Check if there's below threshold price
const belowThresholdPrice = await getBelowThresholdPrice({
sb,
internalCustomerId: customer.internal_id,
@@ -81,6 +86,8 @@ export const runUpdateBalanceTask = async (payload: any) => {
internalCustomerId: payload.internalCustomerId,
belowThresholdPrice,
});
} else {
console.log(" ✅ No below threshold price found");
}
} catch (error) {
console.log(`Error updating customer balance: ${error}`);

View File

@@ -16,6 +16,7 @@ const { data, error } = await sb
.eq("id", customerId)
.eq("env", "sandbox")
.single();
const stripeCusId = data.processor.id;
const stripeCli = new stripe(process.env.STRIPE_TEST_KEY!);

View File

@@ -1,16 +1,23 @@
import { z } from "zod";
export enum InvoiceStatus {
Draft = "draft",
Open = "open",
Void = "void",
Paid = "paid",
Uncollectible = "uncollectible",
}
export const InvoiceSchema = z.object({
id: z.string(),
created_at: z.number(),
internal_customer_id: z.string(),
product_ids: z.array(z.string()),
processor: z.object({
id: z.string(),
type: z.string(),
hosted_invoice_url: z.string().nullable(),
}),
// Stripe fields
stripe_id: z.string(),
status: z.nativeEnum(InvoiceStatus).nullable().optional(),
hosted_invoice_url: z.string().nullable(),
});
export type Invoice = z.infer<typeof InvoiceSchema>;