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