diff --git a/frontend/src/views/customers/customer/CustomerProductList.tsx b/frontend/src/views/customers/customer/CustomerProductList.tsx index ee4d9c798..45462b123 100644 --- a/frontend/src/views/customers/customer/CustomerProductList.tsx +++ b/frontend/src/views/customers/customer/CustomerProductList.tsx @@ -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 = ({ Name Product ID + Status + + Created At + Ended At @@ -42,7 +47,18 @@ export const CustomerProductList = ({ {products.find((p) => p.id === cusProduct.product_id)?.name} - {cusProduct.product_id} + + {cusProduct.product_id} + + {cusProduct.status} + + {formatUnixToDateTimeString(cusProduct.created_at)} + + + {cusProduct.ended_at + ? formatUnixToDateTimeString(cusProduct.ended_at) + : ""} + ); })} diff --git a/frontend/src/views/customers/customer/CustomerView.tsx b/frontend/src/views/customers/customer/CustomerView.tsx index 148a7067f..565eb8716 100644 --- a/frontend/src/views/customers/customer/CustomerView.tsx +++ b/frontend/src/views/customers/customer/CustomerView.tsx @@ -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 ; + const { customer, products, invoices } = data; + const { events } = eventsData; return ( @@ -139,6 +153,7 @@ export default function CustomerView({ Products URL Created At + Status @@ -154,7 +169,7 @@ export default function CustomerView({ + {invoice.status} ))} + +

Events

+
+ {events.map((event) => ( +
+

- {formatUnixToDateTimeString(event.timestamp)}

+

- {event.event_name}

+
+ ))} +
{/* customer details */}
diff --git a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts index ed95cc7b4..58a071b73 100644 --- a/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts +++ b/server/src/external/stripe/webhookHandlers/handleCheckoutCompleted.ts @@ -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, diff --git a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts index 81239ed8a..46424f8bf 100644 --- a/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleInvoicePaid.ts @@ -23,6 +23,7 @@ export const handleInvoicePaid = async ({ if (!cusProduct) { return; } + let existingInvoice = await InvoiceService.getInvoiceByStripeId({ sb, stripeInvoiceId: invoice.id, diff --git a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts index dc8d1fb31..92a5d5419 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubUpdated.ts @@ -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) { diff --git a/server/src/internal/api/customers/cusRouter.ts b/server/src/internal/api/customers/cusRouter.ts index 26afbba73..49485da50 100644 --- a/server/src/internal/api/customers/cusRouter.ts +++ b/server/src/internal/api/customers/cusRouter.ts @@ -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" }); + } +}); diff --git a/server/src/internal/api/customers/products/cusProductRouter.ts b/server/src/internal/api/customers/products/cusProductRouter.ts index b62fc0fba..e97b44d5e 100644 --- a/server/src/internal/api/customers/products/cusProductRouter.ts +++ b/server/src/internal/api/customers/products/cusProductRouter.ts @@ -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, diff --git a/server/src/internal/api/events/EventService.ts b/server/src/internal/api/events/EventService.ts index ca0bf3d79..2a5cb80b6 100644 --- a/server/src/internal/api/events/EventService.ts +++ b/server/src/internal/api/events/EventService.ts @@ -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; + } } diff --git a/server/src/internal/api/events/eventRouter.ts b/server/src/internal/api/events/eventRouter.ts index 11970f5de..70be841bd 100644 --- a/server/src/internal/api/events/eventRouter.ts +++ b/server/src/internal/api/events/eventRouter.ts @@ -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 }); diff --git a/server/src/internal/customers/add-product/createFullCusProduct.ts b/server/src/internal/customers/add-product/createFullCusProduct.ts index 0682e6ed1..976d4edf8 100644 --- a/server/src/internal/customers/add-product/createFullCusProduct.ts +++ b/server/src/internal/customers/add-product/createFullCusProduct.ts @@ -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); diff --git a/server/src/internal/customers/add-product/handleAddProduct.ts b/server/src/internal/customers/add-product/handleAddProduct.ts index e9525a794..78843875c 100644 --- a/server/src/internal/customers/add-product/handleAddProduct.ts +++ b/server/src/internal/customers/add-product/handleAddProduct.ts @@ -180,6 +180,7 @@ export const handleAddProduct = async ({ entitlements, optionsList, subscriptionId: undefined, + billLaterOnly: true, }); console.log("Successfully created full cus product"); diff --git a/server/src/internal/customers/invoices/InvoiceService.ts b/server/src/internal/customers/invoices/InvoiceService.ts index aca8e9e92..88fa6914d 100644 --- a/server/src/internal/customers/invoices/InvoiceService.ts +++ b/server/src/internal/customers/invoices/InvoiceService.ts @@ -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); diff --git a/server/src/trigger/invoiceThresholdUtils.ts b/server/src/trigger/invoiceThresholdUtils.ts index bb8a3c390..b689db444 100644 --- a/server/src/trigger/invoiceThresholdUtils.ts +++ b/server/src/trigger/invoiceThresholdUtils.ts @@ -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) { diff --git a/server/src/trigger/updateBalanceTask.ts b/server/src/trigger/updateBalanceTask.ts index 8d81e334b..ccbdb5f41 100644 --- a/server/src/trigger/updateBalanceTask.ts +++ b/server/src/trigger/updateBalanceTask.ts @@ -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}`); diff --git a/server/test.ts b/server/test.ts index e816a0332..11138b4de 100644 --- a/server/test.ts +++ b/server/test.ts @@ -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!); diff --git a/shared/models/cusModels/invoiceModels/invoiceModels.ts b/shared/models/cusModels/invoiceModels/invoiceModels.ts index 189e3b9bf..70b4a7992 100644 --- a/shared/models/cusModels/invoiceModels/invoiceModels.ts +++ b/shared/models/cusModels/invoiceModels/invoiceModels.ts @@ -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;