added svix on org created and deleting webhooks on org deleted

This commit is contained in:
John Yeo
2025-02-21 15:17:02 +00:00
parent baebf25d9d
commit b9f18786d5
25 changed files with 523 additions and 226 deletions

View File

@@ -93,6 +93,8 @@ export default function CustomerProductView({
const [hasChanges, setHasChanges] = useState(false);
const [useInvoice, setUseInvoice] = useState(false);
const initialProductRef = useRef<FrontendProduct | null>(null);
const [selectedEntitlementAllowance, setSelectedEntitlementAllowance] =
useState<"unlimited" | number>(0);
const searchParams = useSearchParams();
@@ -298,6 +300,8 @@ export default function CustomerProductView({
env,
product,
setProduct,
selectedEntitlementAllowance,
setSelectedEntitlementAllowance,
// prices: product.prices,
// entitlements: product.entitlements,
org,

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"type": "module",
"scripts": {
"dev": "nodemon --no-deprecation --exec tsx src/index.ts --ignore scripts",
"dev": "nodemon --no-deprecation --exec tsx src/index.ts --ignore scripts --ignore tests",
"start": "tsx src/index.ts",
"queue:dev": "tsx watch src/queue.ts",
"build": "tsc -b",
@@ -17,7 +17,8 @@
"clean": "tsx src/clean.ts",
"test-all": "mocha 'tests/**/*.ts'",
"test": "mocha 'tests/**/*.ts'",
"test-custom": "mocha 'tests/02_usage.ts'"
"test-upgrade": "mocha 'tests/06_upgrade.ts' 'tests/07_downgrade.ts'",
"test-custom": "mocha 'tests/06_upgrade.ts'"
},
"mocha": {
"node-option": [

View File

@@ -0,0 +1,52 @@
import express from "express";
import { Webhook } from "svix";
export const autumnWebhookRouter = express.Router();
const verifyAutumnWebhook = async (req: any, res: any) => {
const wh = new Webhook(process.env.AUTUMN_WEBHOOK_SECRET!);
const headers = req.headers;
const payload = req.body;
const svix_id = headers["svix-id"];
const svix_timestamp = headers["svix-timestamp"];
const svix_signature = headers["svix-signature"];
if (!svix_id || !svix_timestamp || !svix_signature) {
res.status(400).json({
success: false,
message: "Error: Missing svix headers",
});
return;
}
let evt: any;
try {
evt = wh.verify(payload, {
"svix-id": svix_id as string,
"svix-timestamp": svix_timestamp as string,
"svix-signature": svix_signature as string,
});
} catch (err) {
console.log("Error: Could not verify webhook");
res.status(400).json({
success: false,
message: "Error: Could not verify webhook",
});
return;
}
return evt;
};
autumnWebhookRouter.post(
"",
express.raw({ type: "application/json" }),
async (req, res) => {
console.log("Webhook from autumn");
const evt = await verifyAutumnWebhook(req, res);
console.log(evt);
}
);

View File

@@ -71,22 +71,26 @@ export const getCusPaymentMethod = async ({
stripeId
)) as Stripe.Customer;
const paymentMethod = stripeCustomer.invoice_settings.default_payment_method;
let paymentMethodId = stripeCustomer.invoice_settings.default_payment_method;
if (!paymentMethod) {
const paymentMethods = await stripeCli.paymentMethods.list({
if (!paymentMethodId) {
let res = await stripeCli.paymentMethods.list({
customer: stripeId,
type: "card",
});
if (paymentMethods.data.length === 0) {
// const paymentMethods = res.data.filter((pm) => pm.type === "card" );
const paymentMethods = res.data;
paymentMethods.sort((a, b) => b.created - a.created);
if (res.data.length === 0) {
return null;
}
return paymentMethods.data[0].id;
return paymentMethods[0].id;
}
return paymentMethod;
return paymentMethodId;
};
// 2. Create a payment method and attach to customer
@@ -107,7 +111,7 @@ export const attachPmToCus = async ({
}) => {
// 1. Create stripe customer if not exists
let stripeCusId = customer.processor?.stripe_id;
let stripeCusId = customer.processor?.id;
if (!stripeCusId) {
const stripeCustomer = await createStripeCustomer({
org,
@@ -126,6 +130,10 @@ export const attachPmToCus = async ({
})
.eq("internal_id", customer.internal_id);
stripeCusId = stripeCustomer.id;
customer.processor = {
id: stripeCustomer.id,
type: "stripe",
};
}
const stripeCli = createStripeCli({ org, env });

View File

@@ -2,7 +2,6 @@ import {
BillingInterval,
BillingType,
FixedPriceConfig,
PriceOptions,
Organization,
FullProduct,
Price,
@@ -13,6 +12,7 @@ import {
Product,
AllowanceType,
EntitlementWithFeature,
CusProductStatus,
} from "@autumn/shared";
import RecaseError from "@/utils/errorUtils.js";
@@ -20,6 +20,8 @@ import { ErrCode } from "@/errors/errCodes.js";
import Stripe from "stripe";
import {
getBillingType,
getCheckoutRelevantPrices,
getEntOptions,
getPriceAmount,
getPriceEntitlement,
getPriceOptions,
@@ -27,6 +29,7 @@ import {
import { PriceService } from "@/internal/prices/PriceService.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { AttachParams } from "@/internal/customers/products/AttachParams.js";
import { createStripeCli } from "./utils.js";
export const billingIntervalToStripe = (interval: BillingInterval) => {
switch (interval) {
case BillingInterval.Month:
@@ -54,20 +57,19 @@ export const billingIntervalToStripe = (interval: BillingInterval) => {
}
};
// GET STRIPE LINE / SUB ITEM
export const priceToStripeItem = ({
price,
product,
org,
options,
isCheckout = false,
relatedEnt,
}: {
price: Price;
product: FullProduct;
org: Organization;
options: FeatureOptions | undefined | null;
isCheckout: boolean;
relatedEnt: EntitlementWithFeature | undefined;
}) => {
// TODO: Implement this
const billingType = price.billing_type;
@@ -108,7 +110,12 @@ export const priceToStripeItem = ({
};
} else if (billingType == BillingType.UsageInAdvance) {
const config = price.config as UsagePriceConfig;
const quantity = options?.quantity || 1;
// const quantity = options?.quantity || 1;
if (options?.quantity === 0 && isCheckout) {
console.log(`Quantity for ${config.feature_id} is 0`);
return null;
}
const adjustableQuantity = isCheckout
? {
@@ -126,7 +133,7 @@ export const priceToStripeItem = ({
lineItem = {
price: config.stripe_price_id,
quantity,
quantity: options?.quantity!,
adjustable_quantity: adjustableQuantity,
};
lineItemMeta = {
@@ -158,6 +165,74 @@ export const priceToStripeItem = ({
};
};
// STRIPE TO SUB ITEMS
export const getStripeSubItems = async ({
attachParams,
isCheckout = false,
}: {
attachParams: AttachParams;
isCheckout?: boolean;
}) => {
const { product, prices, entitlements, optionsList, org, curCusProduct } =
attachParams;
const checkoutRelevantPrices = getCheckoutRelevantPrices(prices);
let subItems: any[] = [];
let itemMetas: any[] = [];
// TODO: Check if non bill now prices can be added to stripe subscription...?
// // 1. Check current period end...
// if (curCusProduct && curCusProduct.processor?.subscription_id) {
// const subId = curCusProduct.processor.subscription_id;
// const stripeCli = createStripeCli({
// org,
// env: curCusProduct.customer.env,
// });
// const sub = await stripeCli.subscriptions.retrieve(subId);
// const prorationConfig: any = {};
// if (sub.status !== CusProductStatus.Trialing) {
// const curPeriodStart = sub.current_period_start * 1000;
// const curPeriodEnd = sub.current_period_end * 1000;
// prorationConfig.current_period_start = curPeriodStart;
// prorationConfig.current_period_end = curPeriodEnd;
// const curPrices = curCusProduct.customer_prices.map((p) => p.price!);
// prorationConfig.curPrices = curPrices;
// }
// }
for (const price of checkoutRelevantPrices) {
const priceEnt = getPriceEntitlement(price, entitlements);
const options = getEntOptions(optionsList, priceEnt);
const stripeItem = priceToStripeItem({
price,
product,
org,
options,
isCheckout,
});
if (!stripeItem) {
continue;
}
const { lineItem, lineItemMeta } = stripeItem;
subItems.push(lineItem);
itemMetas.push(lineItemMeta);
}
console.log("Line items: ", subItems);
return { items: subItems, itemMetas };
};
export const inAdvanceToStripeTiers = (
price: Price,
entitlement: Entitlement

View File

@@ -127,7 +127,7 @@ export const updateStripeSubscription = async ({
});
return subUpdate;
} catch (error: any) {
console.log("Error updating stripe subscription", error.message);
console.log("Error updating stripe subscription.", error.message);
if (isStripeCardDeclined(error)) {
throw new RecaseError({

View File

@@ -3,7 +3,7 @@ import { Stripe } from "stripe";
import { createFullCusProduct } from "@/internal/customers/add-product/createFullCusProduct.js";
import { CusProductService } from "@/internal/customers/products/CusProductService.js";
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
import { AppEnv, Organization, UsagePriceConfig } from "@autumn/shared";
import { AppEnv, Organization } from "@autumn/shared";
import { AttachParams } from "@/internal/customers/products/AttachParams.js";
import { createStripeCli } from "../utils.js";
import { InvoiceService } from "@/internal/customers/invoices/InvoiceService.js";
@@ -121,6 +121,8 @@ export const handleCheckoutSessionCompleted = async ({
}
}
// Handle upgrade / downgrade
console.log(" - checkout.completed: creating full customer product");
await createFullCusProduct({

39
server/src/external/svix/svixUtils.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
import { AppEnv } from "@shared/models/genModels.js";
import { Svix } from "svix";
export const createSvixCli = () => {
return new Svix(process.env.SVIX_API_KEY as string);
};
export const createSvixApp = async ({
name,
orgId,
env,
}: {
name: string;
orgId: string;
env: AppEnv;
}) => {
const svix = createSvixCli();
const app = await svix.application.create({
name,
metadata: {
org_id: orgId,
env,
},
});
return app;
};
export const deleteSvixApp = async ({ appId }: { appId: string }) => {
const svix = createSvixCli();
await svix.application.delete(appId);
};
export const sendSvixEvent = async (event: any) => {
const svix = createSvixCli();
await svix.message.create("app_2tKDzBZtEBMQoybfckgdnb3BlJ0", {
eventType: "product.attached",
payload: event,
});
};

View File

@@ -24,6 +24,12 @@ import { ProductService } from "@/internal/products/ProductService.js";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService.js";
import { PriceService } from "@/internal/prices/PriceService.js";
import { getBillingType } from "@/internal/prices/priceUtils.js";
import { createSvixApp, deleteSvixApp } from "../svix/svixUtils.js";
import {
deleteStripeWebhook,
initOrgSvixApps,
} from "@/internal/orgs/orgUtils.js";
import { createStripeCli } from "../stripe/utils.js";
const defaultFeatures = [
{
@@ -222,11 +228,13 @@ export const handleClerkWebhook = async (req: any, res: any) => {
break;
case "organization.deleted":
await OrgService.delete({
sb: req.sb,
orgId: eventData.id,
});
console.log(`Deleted org ${eventData.id}`);
await handleOrgDeleted(req.sb, eventData);
break;
// await OrgService.delete({
// sb: req.sb,
// orgId: eventData.id,
// });
// console.log(`Deleted org ${eventData.id}`);
default:
break;
@@ -248,7 +256,17 @@ export const handleClerkWebhook = async (req: any, res: any) => {
};
const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => {
console.log(
`Handling organization.created: ${eventData.slug} (${eventData.id})`
);
try {
// 1. Create svix webhoooks
const { sandboxApp, liveApp } = await initOrgSvixApps({
slug: eventData.slug,
id: eventData.id,
});
// 2. Insert org
await OrgService.insert({
sb,
org: {
@@ -259,6 +277,11 @@ const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => {
stripe_config: null,
test_pkey: generatePublishableKey(AppEnv.Sandbox),
live_pkey: generatePublishableKey(AppEnv.Live),
created_at: eventData.created_at,
svix_config: {
sandbox_app_id: sandboxApp.id,
live_app_id: liveApp.id,
},
},
});
@@ -292,3 +315,63 @@ const handleOrgCreated = async (sb: SupabaseClient, eventData: any) => {
);
}
};
const handleOrgDeleted = async (sb: SupabaseClient, eventData: any) => {
console.log(
`Handling organization.deleted: ${eventData.slug} (${eventData.id})`
);
const org = await OrgService.getFullOrg({
sb,
orgId: eventData.id,
});
// 1. Delete svix webhooks
try {
console.log("1. Deleting svix webhooks");
const batch = [];
if (org.svix_config.sandbox_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.sandbox_app_id,
})
);
}
if (org.svix_config.live_app_id) {
batch.push(
deleteSvixApp({
appId: org.svix_config.live_app_id,
})
);
}
await Promise.all(batch);
// 2. Delete stripe webhooks
console.log("2. Deleting stripe webhooks");
if (org.stripe_config) {
await deleteStripeWebhook({
org: org,
env: AppEnv.Sandbox,
});
await deleteStripeWebhook({
org: org,
env: AppEnv.Live,
});
}
// 3. Delete org
console.log("3. Deleting org");
await OrgService.delete({
sb,
orgId: eventData.id,
});
console.log(`Deleted org ${org.slug} (${org.id})`);
} catch (error) {
console.log("Failed to delete organization", error);
return;
}
};

View File

@@ -2,11 +2,14 @@ import express from "express";
import bodyParser from "body-parser";
import { handleClerkWebhook } from "./clerkWebhooks.js";
import { stripeWebhookRouter } from "../stripe/stripeWebhooks.js";
import { autumnWebhookRouter } from "../autumn/autumnWebhookRouter.js";
const webhooksRouter = express.Router();
webhooksRouter.use("/stripe", stripeWebhookRouter);
webhooksRouter.use("/autumn", autumnWebhookRouter);
webhooksRouter.post(
"/clerk",
bodyParser.raw({ type: "application/json" }),

View File

@@ -557,15 +557,17 @@ cusRouter.post(
// Reactivate current product
const curActiveProducts = await CusService.getFullCusProducts({
sb: req.sb,
internalCustomerId: cusProduct.customer.internal_id,
withProduct: true,
internalCustomerId: cusProduct.internal_customer_id,
inStatuses: [CusProductStatus.Active],
productGroup: cusProduct.product.group,
withProduct: true,
});
const activeProducts = curActiveProducts.filter(
(p: any) => p.product.group == cusProduct.product.group
);
for (const activeProduct of activeProducts) {
for (const activeProduct of curActiveProducts) {
console.log(
"Reactivating current product:",
activeProduct.product.name
);
await stripeCli.subscriptions.update(
activeProduct.processor.subscription_id!,
{
@@ -596,61 +598,6 @@ cusRouter.post(
});
}
}
// console.log(cusProduct);
// if (!cusProduct.product.is_add_on) {
// if (cusProduct.status == CusProductStatus.Scheduled) {
// console.log(
// `Cancelling scheduled product ${cusProduct.product.name} for ${customerId}`
// );
// try {
// await stripeCli.subscriptionSchedules.cancel(
// cusProduct.processor.subscription_schedule_id!
// );
// } catch (error: any) {
// console.log("Failed to cancel scheduled product:", error.message);
// }
// console.log("Updating status to expired");
// await CusProductService.deleteFutureProduct({
// sb: req.sb,
// internalCustomerId: cusProduct.customer.internal_id,
// productGroup: cusProduct.product.group,
// });
// // Re activate current product
// console.log("Reactivating current product");
// const curActiveProducts = await CusService.getFullCusProducts({
// sb: req.sb,
// internalCustomerId: cusProduct.customer.internal_id,
// withProduct: true,
// inStatuses: [CusProductStatus.Active],
// });
// const activeProducts = curActiveProducts.filter(
// (p: any) => p.product.group == cusProduct.product.group
// );
// for (const activeProduct of activeProducts) {
// await stripeCli.subscriptions.update(
// activeProduct.processor.subscription_id!,
// {
// cancel_at: null,
// }
// );
// }
// } else if (!cusProduct.processor.subscription_id) {
// // Don't need to delete, stripe will do it...
// // console.log(
// // `Expiring product ${cusProduct.product.name} for ${customerId} (attaching default if exists)`
// // );
// await expireAndAddDefaultProduct({
// sb: req.sb,
// org,
// env: req.env,
// cusProduct,
// });
// }
// }
}
if (!cusProduct) {

View File

@@ -26,7 +26,10 @@ import {
} from "@/internal/prices/priceUtils.js";
import { PricesInput } from "@autumn/shared";
import { getFullCusProductData } from "../../../customers/products/cusProductUtils.js";
import { isFreeProduct } from "@/internal/products/productUtils.js";
import {
isFreeProduct,
isProductUpgrade,
} from "@/internal/products/productUtils.js";
import { handleAddFreeProduct } from "@/internal/customers/add-product/handleAddFreeProduct.js";
import { handleCreateCheckout } from "@/internal/customers/add-product/handleCreateCheckout.js";
import { createStripeCli } from "@/external/stripe/utils.js";
@@ -41,6 +44,7 @@ import {
createStripePriceIFNotExist,
} from "@/external/stripe/stripePriceUtils.js";
import { handleInvoiceOnly } from "@/internal/customers/add-product/handleInvoiceOnly.js";
import { notNullOrUndefined } from "@/utils/genUtils.js";
export const attachRouter = Router();
@@ -88,13 +92,21 @@ export const checkAddProductErrors = async ({
// Get options for price
let priceEnt = getPriceEntitlement(price, entitlements);
let options = getEntOptions(optionsList, priceEnt);
if (!options?.quantity) {
if (!notNullOrUndefined(options?.quantity)) {
throw new RecaseError({
message: `Pass in 'quantity' for feature ${priceEnt.feature_id} in options`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
if (options?.quantity === 0 && prices.length === 0) {
throw new RecaseError({
message: `When there's only one price, quantity must be greater than 0`,
code: ErrCode.InvalidOptions,
statusCode: 400,
});
}
} else if (billingType === BillingType.UsageBelowThreshold) {
let priceEnt = getPriceEntitlement(price, entitlements);
let options = getEntOptions(optionsList, priceEnt);
@@ -194,23 +206,30 @@ export const handleExistingProduct = async ({
});
}
const curPrices =
currentProduct?.customer_prices.map((cp: any) => cp.price) || [];
// If there's current product and it's not free and new product is a switch
if (
currentProduct &&
!isFreeProduct(curPrices) &&
!product.is_add_on &&
useCheckout
) {
throw new RecaseError({
message: `Can't use checkout for upgrades / downgrades`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
if (currentProduct && useCheckout) {
// If not downgrade to free, throw error
let downgradeToFree =
!isProductUpgrade(currentProduct.product, product) &&
isFreeProduct(attachParams.prices);
let upgradeFromFree =
isProductUpgrade(currentProduct.product, product) &&
isFreeProduct(
currentProduct?.customer_prices.map((cp: any) => cp.price) || []
);
let isAddOn = attachParams.product.is_add_on;
if (!downgradeToFree && !upgradeFromFree && !isAddOn) {
throw new RecaseError({
message: `Either payment method not found, or force_checkout is true: unable to perform upgrade / downgrade`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
}
return { currentProduct, done: false };
return { curCusProduct: currentProduct, done: false };
};
export const checkStripeConnections = async ({
@@ -295,6 +314,21 @@ export const checkStripeConnections = async ({
await Promise.all(batchPriceUpdates);
};
export const customerHasPm = async ({
attachParams,
}: {
attachParams: AttachParams;
}) => {
// SCENARIO 3: No payment method, checkout required
const paymentMethod = await getCusPaymentMethod({
org: attachParams.org,
env: attachParams.customer.env,
stripeId: attachParams.customer.processor.id,
});
return notNullOrUndefined(paymentMethod) ? true : false;
};
attachRouter.post("/attach", async (req: any, res) => {
const {
customer_id,
@@ -318,14 +352,13 @@ attachRouter.post("/attach", async (req: any, res) => {
const optionsListInput: FeatureOptions[] = options || [];
const invoiceOnly = invoice_only || false;
const useCheckout = force_checkout || false;
let forceCheckout = force_checkout || false;
console.log("--------------------------------");
console.log(`ATTACH PRODUCT REQUEST (from ${req.minOrg.slug})`);
try {
z.array(FeatureOptionsSchema).parse(optionsListInput);
// 1. Get full customer product data
const attachParams = await getFullCusProductData({
const attachParams: AttachParams = await getFullCusProductData({
sb,
customerId: customer_id,
productId: product_id,
@@ -339,6 +372,23 @@ attachRouter.post("/attach", async (req: any, res) => {
isCustom: is_custom,
});
console.log(
`Customer: ${chalk.yellow(
`${attachParams.customer.id} (${attachParams.customer.name})`
)}`
);
// 3. Check for stripe connection
await checkStripeConnections({ req, res, attachParams });
let hasPm = await customerHasPm({ attachParams });
const useCheckout = !hasPm || forceCheckout;
console.log(
`Has PM: ${chalk.yellow(hasPm)}, Force Checkout: ${chalk.yellow(
forceCheckout
)}, Use Checkout: ${chalk.yellow(useCheckout)}`
);
// -------------------- ERROR CHECKING --------------------
// 1. Check for normal errors (eg. options, different recurring intervals)
@@ -347,14 +397,8 @@ attachRouter.post("/attach", async (req: any, res) => {
useCheckout,
});
console.log(
`Customer: ${chalk.yellow(
`${attachParams.customer.id} (${attachParams.customer.name})`
)}`
);
// 2. Check for existing product and fetch
const { currentProduct, done } = await handleExistingProduct({
const { curCusProduct, done } = await handleExistingProduct({
req,
res,
attachParams,
@@ -363,20 +407,18 @@ attachRouter.post("/attach", async (req: any, res) => {
if (done) return;
// 3. Check for stripe connection
await checkStripeConnections({ req, res, attachParams });
// -------------------- ATTACH PRODUCT --------------------
// SCENARIO 1: Free product, no existing product
const curProductFree = isFreeProduct(
currentProduct?.customer_prices.map((cp: any) => cp.price) || [] // if no current product...
curCusProduct?.customer_prices.map((cp: any) => cp.price) || [] // if no current product...
);
const newProductFree = isFreeProduct(attachParams.prices);
attachParams.curCusProduct = !curProductFree ? curCusProduct : null;
if (
(!currentProduct && newProductFree) ||
(!curCusProduct && newProductFree) ||
(curProductFree && newProductFree) ||
(attachParams.product.is_add_on && newProductFree)
) {
@@ -395,19 +437,12 @@ attachRouter.post("/attach", async (req: any, res) => {
req,
res,
attachParams,
curCusProduct: currentProduct,
curCusProduct,
});
return;
}
// SCENARIO 3: No payment method, checkout required
const paymentMethod = await getCusPaymentMethod({
org: attachParams.org,
env: attachParams.customer.env,
stripeId: attachParams.customer.processor.id,
});
if (!paymentMethod || useCheckout) {
if (useCheckout) {
console.log("SCENARIO 2: NO PAYMENT METHOD, CHECKOUT REQUIRED");
await handleCreateCheckout({
sb,
@@ -418,13 +453,14 @@ attachRouter.post("/attach", async (req: any, res) => {
}
// SCENARIO 4: Switching product
if (!attachParams.product.is_add_on && currentProduct) {
if (!attachParams.product.is_add_on && curCusProduct) {
console.log("SCENARIO 3: SWITCHING PRODUCT (PAYMENT METHOD EXISTS)");
await handleChangeProduct({
req,
res,
attachParams,
curCusProduct: currentProduct,
curCusProduct,
});
return;
}

View File

@@ -536,12 +536,14 @@ export class CusService {
withPrices = false,
withProduct = false,
inStatuses,
productGroup,
}: {
sb: SupabaseClient;
internalCustomerId: string;
withProduct?: boolean;
withPrices?: boolean;
inStatuses?: CusProductStatus[];
productGroup?: string;
}) {
const selectQuery = [
"*",
@@ -549,7 +551,7 @@ export class CusService {
withPrices
? "customer_prices:customer_prices(*, price:prices!inner(*))"
: "",
`customer_entitlements:customer_entitlements!inner(*,
`customer_entitlements:customer_entitlements(*,
entitlement:entitlements(*,
feature:features!inner(*)
)
@@ -567,6 +569,10 @@ export class CusService {
query.in("status", inStatuses);
}
if (productGroup) {
query.eq("product.group", productGroup);
}
// query.limit(100);
// TODO: Limit 100 cus products? (for one time add ons...)
// SORT by created_at?

View File

@@ -9,12 +9,11 @@ import {
FeatureOptions,
FreeTrial,
BillingType,
UsagePriceConfig,
CollectionMethod,
} from "@autumn/shared";
import { generateId } from "@/utils/genUtils.js";
import { getNextEntitlementReset } from "@/utils/timeUtils.js";
import { Customer, Feature, FeatureType } from "@autumn/shared";
import { Customer, FeatureType } from "@autumn/shared";
import { EntitlementWithFeature, FullProduct } from "@autumn/shared";
import { SupabaseClient } from "@supabase/supabase-js";
import { ErrCode } from "@/errors/errCodes.js";

View File

@@ -3,7 +3,6 @@ import {
getBillNowPrices,
getPriceEntitlement,
getPriceOptions,
getStripeSubItems,
pricesOnlyOneOff,
} from "@/internal/prices/priceUtils.js";
@@ -20,6 +19,7 @@ import { InvoiceService } from "../invoices/InvoiceService.js";
import { payForInvoice } from "@/external/stripe/stripeInvoiceUtils.js";
import { createStripeSubscription } from "@/external/stripe/stripeSubUtils.js";
import { handleCreateCheckout } from "./handleCreateCheckout.js";
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
const handleBillNowPrices = async ({
sb,
@@ -34,7 +34,7 @@ const handleBillNowPrices = async ({
const stripeCli = createStripeCli({ org, env: customer.env });
const { items, itemMetas } = getStripeSubItems({
const { items, itemMetas } = await getStripeSubItems({
attachParams,
});

View File

@@ -1,15 +1,14 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import {
getStripeSubItems,
pricesContainRecurring,
} from "@/internal/prices/priceUtils.js";
import { pricesContainRecurring } from "@/internal/prices/priceUtils.js";
import { createCheckoutMetadata } from "@/internal/metadata/metadataUtils.js";
import { AttachParams } from "../products/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import { SupabaseClient } from "@supabase/supabase-js";
import { BillingType, FixedPriceConfig } from "@autumn/shared";
import { differenceInDays, format } from "date-fns";
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
export const handleCreateCheckout = async ({
sb,
res,
@@ -23,7 +22,7 @@ export const handleCreateCheckout = async ({
`Creating checkout for customer ${attachParams.customer.id}, product ${attachParams.product.name}`
);
const { customer, org, freeTrial } = attachParams;
const { customer, org, freeTrial, curCusProduct } = attachParams;
const stripeCli = createStripeCli({
org,
@@ -31,7 +30,7 @@ export const handleCreateCheckout = async ({
});
// Get stripeItems
const { items, itemMetas } = getStripeSubItems({
const { items, itemMetas } = await getStripeSubItems({
attachParams,
isCheckout: true,
});

View File

@@ -2,7 +2,6 @@ import { createStripeSubscription } from "@/external/stripe/stripeSubUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import {
getBillNowPrices,
getStripeSubItems,
pricesOnlyOneOff,
} from "@/internal/prices/priceUtils.js";
import { createFullCusProduct } from "./createFullCusProduct.js";
@@ -18,7 +17,10 @@ import {
import { SupabaseClient } from "@supabase/supabase-js";
import { CusProductService } from "../products/CusProductService.js";
import Stripe from "stripe";
import { pricesToInvoiceItems } from "@/external/stripe/stripePriceUtils.js";
import {
getStripeSubItems,
pricesToInvoiceItems,
} from "@/external/stripe/stripePriceUtils.js";
export const voidLatestInvoice = async ({
stripeCli,
@@ -174,7 +176,7 @@ export const handleInvoiceOnly = async ({
// 1. Create stripe subscription (with invoice)
console.log(" - Creating stripe subscription");
const { items, itemMetas } = getStripeSubItems({
const { items, itemMetas } = await getStripeSubItems({
attachParams,
});

View File

@@ -1,5 +1,5 @@
import { createStripeCli } from "@/external/stripe/utils.js";
import { getStripeSubItems } from "@/internal/prices/priceUtils.js";
import { getStripeSubItems } from "@/external/stripe/stripePriceUtils.js";
import { ProductService } from "@/internal/products/ProductService.js";
import {
isFreeProduct,
@@ -16,10 +16,8 @@ import { handleAddProduct } from "../add-product/handleAddProduct.js";
import { CusProductService } from "../products/CusProductService.js";
import { AttachParams } from "../products/AttachParams.js";
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
import chalk from "chalk";
import RecaseError, { isPaymentDeclined } from "@/utils/errorUtils.js";
import { updateStripeSubscription } from "@/external/stripe/stripeSubUtils.js";
import { handleCreateCheckout } from "../add-product/handleCreateCheckout.js";
import { InvoiceService } from "../invoices/InvoiceService.js";
const scheduleStripeSubscription = async ({
attachParams,
@@ -32,7 +30,7 @@ const scheduleStripeSubscription = async ({
}) => {
const { org, customer } = attachParams;
const { items, itemMetas } = getStripeSubItems({
const { items, itemMetas } = await getStripeSubItems({
attachParams,
});
@@ -151,7 +149,7 @@ const handleStripeSubUpdate = async ({
const subscription = await stripeCli.subscriptions.retrieve(subscriptionId);
// Get stripe subscription from product
const { items, itemMetas } = getStripeSubItems({
const { items, itemMetas } = await getStripeSubItems({
attachParams,
});
@@ -215,10 +213,27 @@ const handleUpgrade = async ({
return;
}
const disableFreeTrial =
curFullProduct.free_trial && org.config?.free_trial_paid_to_paid;
// 2. If current product is a trial, just start a new period
if (curCusProduct.trial_ends_at && curCusProduct.trial_ends_at > Date.now()) {
console.log(
"NOTE: Current product is a trial, cancel and start new subscription"
);
// Maybe do it such that if cur cus product has no subscription ID, we just create a new one?
await handleAddProduct({
req,
res,
attachParams,
});
await stripeCli.subscriptions.cancel(
curCusProduct.processor?.subscription_id!
);
return;
}
// const disableFreeTrial =
// curCusProduct.free_trial_id && org.config?.free_trial_paid_to_paid;
const disableFreeTrial = false;
console.log("1. Updating current subscription to new product");
let subUpdate;
@@ -226,6 +241,7 @@ const handleUpgrade = async ({
subscriptionId: curCusProduct.processor?.subscription_id!,
stripeCli,
attachParams,
disableFreeTrial,
});
// Handle backend
@@ -240,6 +256,19 @@ const handleUpgrade = async ({
disableFreeTrial,
});
// // Insert latest invoice
// const stripeInvoice = await stripeCli.invoices.retrieve(
// subUpdate.latest_invoice as string
// );
// await InvoiceService.createInvoiceFromStripe({
// sb: req.sb,
// stripeInvoice,
// internalCustomerId: customer.id,
// org: org,
// productIds: [product.id],
// internalProductIds: [product.id],
// });
res.status(200).json({ success: true, message: "Product change handled" });
};
@@ -259,7 +288,7 @@ export const handleChangeProduct = async ({
const { org, customer, product, prices, entitlements, optionsList } =
attachParams;
const curFullProduct = await ProductService.getFullProduct({
const curFullProduct = await ProductService.getFullProductStrict({
sb: req.sb,
productId: curProduct.id,
orgId: org.id,

View File

@@ -3,6 +3,7 @@ import {
EntitlementWithFeature,
FeatureOptions,
FreeTrial,
FullCusProduct,
FullProduct,
Organization,
Price,
@@ -17,4 +18,6 @@ export type AttachParams = {
entitlements: EntitlementWithFeature[];
freeTrial: FreeTrial | null;
optionsList: FeatureOptions[];
curCusProduct?: FullCusProduct | undefined;
};

View File

@@ -90,15 +90,29 @@ export class CusProductService {
static async getByInternalCusId({
sb,
cusId,
inStatuses,
productGroup,
}: {
sb: SupabaseClient;
cusId: string;
inStatuses?: string[];
productGroup?: string;
}) {
const { data, error } = await sb
const query = sb
.from("customer_products")
.select("*, product:products!inner(*)")
.eq("internal_customer_id", cusId);
if (inStatuses) {
query.in("status", inStatuses);
}
if (productGroup) {
query.eq("product.group", productGroup);
}
const { data, error } = await query;
if (error) {
throw error;
}
@@ -120,7 +134,7 @@ export class CusProductService {
.select(
`
*,
product:products!inner(*),
product:products!inner(*, prices(*)),
customer_prices:customer_prices(*, price:prices!inner(*))
`
)

View File

@@ -8,6 +8,8 @@ import {
Organization,
AppEnv,
FeatureOptions,
CusProduct,
FullCusProduct,
} from "@autumn/shared";
import { generateId } from "@/utils/genUtils.js";

View File

@@ -1,6 +1,60 @@
import { decryptData } from "@/utils/encryptUtils.js";
import RecaseError from "@/utils/errorUtils.js";
import { AppEnv, ErrCode, Organization } from "@autumn/shared";
import { createSvixApp } from "@/external/svix/svixUtils.js";
import { createStripeCli } from "@/external/stripe/utils.js";
export const initOrgSvixApps = async ({
id,
slug,
}: {
id: string;
slug: string;
}) => {
const batchCreate = [];
batchCreate.push(
createSvixApp({
name: `${slug}_${AppEnv.Sandbox}`,
orgId: id,
env: AppEnv.Sandbox,
})
);
batchCreate.push(
createSvixApp({
name: `${slug}_${AppEnv.Live}`,
orgId: id,
env: AppEnv.Live,
})
);
const [sandboxApp, liveApp] = await Promise.all(batchCreate);
return { sandboxApp, liveApp };
};
export const deleteStripeWebhook = async ({
org,
env,
}: {
org: Organization;
env: AppEnv;
}) => {
const stripeCli = createStripeCli({ org, env });
const webhookEndpoints = await stripeCli.webhookEndpoints.list({
limit: 100,
});
for (const webhook of webhookEndpoints.data) {
if (webhook.url.includes(org.id)) {
try {
await stripeCli.webhookEndpoints.del(webhook.id);
} catch (error: any) {
console.log(`Failed to delete stripe webhook (${env}) ${webhook.url}`);
console.log(error.message);
}
}
}
};
export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => {
if (!org.stripe_config) {

View File

@@ -215,44 +215,6 @@ export function compareBillingIntervals(
return priority[a] - priority[b];
}
// Stripe items
export const getStripeSubItems = ({
attachParams,
isCheckout = false,
}: {
attachParams: AttachParams;
isCheckout?: boolean;
}) => {
const { product, prices, entitlements, optionsList, org } = attachParams;
// const billNowPrices = getBillNowPrices(prices);
const checkoutRelevantPrices = getCheckoutRelevantPrices(prices);
let subItems: any[] = [];
let itemMetas: any[] = [];
// TODO: Check if non bill now prices can be added to stripe subscription...?
for (const price of checkoutRelevantPrices) {
const priceEnt = getPriceEntitlement(price, entitlements);
const options = getEntOptions(optionsList, priceEnt);
const { lineItem, lineItemMeta } = priceToStripeItem({
price,
product,
org,
options,
isCheckout,
relatedEnt: priceEnt,
});
subItems.push(lineItem);
itemMetas.push(lineItemMeta);
}
console.log("Line items: ", subItems);
return { items: subItems, itemMetas };
};
export const getUsageTier = (price: Price, quantity: number) => {
let usageConfig = price.config as UsagePriceConfig;
for (let i = 0; i < usageConfig.usage_tiers.length; i++) {

View File

@@ -197,40 +197,6 @@ export class ProductService {
return data;
}
static async getFullProduct({
sb,
productId,
orgId,
env,
}: {
sb: SupabaseClient;
productId: string;
orgId: string;
env: AppEnv;
}) {
const { data, error } = await sb
.from("products")
.select(
`*,
entitlements (
*,
feature:features (id, name, type)
),
prices (*)
`
)
.eq("id", productId)
.eq("org_id", orgId)
.eq("env", env)
.single();
if (error) {
throw error;
}
return data;
}
static async getFullProductStrict({
sb,
productId,

View File

@@ -13,6 +13,11 @@ export const StripeConfigSchema = z.object({
success_url: z.string(),
});
export const SvixConfigSchema = z.object({
sandbox_app_id: z.string(),
live_app_id: z.string(),
});
export const OrganizationSchema = z.object({
id: z.string(),
slug: z.string(),
@@ -21,6 +26,12 @@ export const OrganizationSchema = z.object({
stripe_config: StripeConfigSchema.optional().nullable(),
test_pkey: z.string(),
live_pkey: z.string(),
created_at: z.number(),
svix_config: z.object({
sandbox_app_id: z.string(),
live_app_id: z.string(),
}),
config: z
.object({