feat: platform exchange endpoint

This commit is contained in:
John Yeo
2025-07-25 09:59:08 +01:00
parent e362c91bef
commit 8eeedad2c2
30 changed files with 631 additions and 173 deletions

View File

@@ -111,7 +111,6 @@ export const createStripeCustomer = async ({
return stripeCustomer;
} catch (error: any) {
console.log("error", error);
throw new RecaseError({
message: `Error creating customer in Stripe. ${error.message}`,
code: ErrCode.StripeCreateCustomerFailed,

View File

@@ -66,12 +66,12 @@ export const createStripeSub = async ({
let subItems = items.filter(
(i: any, index: number) =>
prices[index].config!.interval !== BillingInterval.OneOff,
prices[index].config!.interval !== BillingInterval.OneOff
);
let invoiceItems = items.filter(
(i: any, index: number) =>
prices[index].config!.interval === BillingInterval.OneOff,
prices[index].config!.interval === BillingInterval.OneOff
);
try {

View File

@@ -20,16 +20,18 @@ export const createStripeCli = ({
org: Organization;
env: AppEnv;
}) => {
if (!org.stripe_config) {
throw new RecaseError({
message: "Stripe config not found",
code: ErrCode.StripeConfigNotFound,
});
}
let encrypted =
env == AppEnv.Sandbox
? org.stripe_config.test_api_key
: org.stripe_config.live_api_key;
? org.stripe_config?.test_api_key
: org.stripe_config?.live_api_key;
if (!encrypted) {
throw new RecaseError({
message: `Please connect your Stripe ${env == AppEnv.Sandbox ? "test" : "live"} keys. You can find them here: https://dashboard.stripe.com${env == AppEnv.Sandbox ? "/test" : ""}/apikeys`,
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
}
let decrypted = decryptData(encrypted);
return new Stripe(decrypted);

View File

@@ -63,7 +63,7 @@ export const handlePaidProduct = async ({
let mergeCusProduct = undefined;
if (!config.disableMerge && !freeTrial) {
mergeCusProduct = cusProducts?.find((cp) =>
products.some((p) => p.group == cp.product.group),
products.some((p) => p.group == cp.product.group)
);
}
@@ -79,7 +79,7 @@ export const handlePaidProduct = async ({
}
let mergeWithSub = mergeSubs.find(
(sub) => subToAutumnInterval(sub) == itemSet.interval,
(sub) => subToAutumnInterval(sub) == itemSet.interval
);
let subscription;
@@ -150,7 +150,7 @@ export const handlePaidProduct = async ({
carryExistingUsages: config.carryUsage,
scenario: AttachScenario.New,
logger,
}),
})
);
}
await Promise.all(batchInsert);
@@ -163,7 +163,7 @@ export const handlePaidProduct = async ({
invoiceId: sub.latest_invoice as string,
attachParams,
logger,
}),
})
);
}
const invoices = await Promise.all(batchInsertInvoice);
@@ -180,7 +180,7 @@ export const handlePaidProduct = async ({
product_ids: products.map((p) => p.id),
customer_id: customer.id || customer.internal_id,
invoice: invoiceOnly ? invoices?.[0] : undefined,
}),
})
);
} else {
res.status(200).json({

View File

@@ -49,6 +49,7 @@ export const getPricesAndEnts = async ({
let freeTrial = null;
let freeTrialProduct = products.find((p) => notNullish(p.free_trial));
// freeTrial = freeTrialProduct?.free_trial;
if (freeTrialProduct) {
freeTrial = await getFreeTrialAfterFingerprint({
db,
@@ -60,8 +61,6 @@ export const getPricesAndEnts = async ({
});
}
const prodIsMain = isMainProduct({ product: products[0], prices });
return {
optionsList: mapOptionsList({
optionsInput: optionsInput || [],

View File

@@ -57,20 +57,13 @@ export const processAttachBody = async ({
// 1. Get customer and products
const { org, env } = req;
if (!org.stripe_connected) {
throw new RecaseError({
message: "Please connect to Stripe to add products",
code: ErrCode.StripeConfigNotFound,
statusCode: 400,
});
}
const stripeCli = createStripeCli({ org, env });
const { customer, products } = await getCustomerAndProducts({
req,
attachBody,
});
const stripeCli = createStripeCli({ org, env });
const [stripeCusData, rewardData] = await Promise.all([
getStripeCusData({
stripeCli,

View File

@@ -88,6 +88,7 @@ export const attachParamsToPreview = async ({
branch,
now,
withPrepaid,
config,
});
}

View File

@@ -5,10 +5,7 @@ import {
} from "../attachUtils/convertAttachParams.js";
import { getStripeSubs } from "@/external/stripe/stripeSubUtils.js";
import { ExtendedRequest } from "@/utils/models/Request.js";
import {
getFirstInterval,
getLastInterval,
} from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { getFirstInterval } from "@/internal/products/prices/priceUtils/priceIntervalUtils.js";
import { getItemsForNewProduct } from "@/internal/invoices/previewItemUtils/getItemsForNewProduct.js";
import { getItemsForCurProduct } from "@/internal/invoices/previewItemUtils/getItemsForCurProduct.js";
import { getOptions } from "@/internal/api/entitled/checkUtils.js";
@@ -18,9 +15,11 @@ import {
AttachBranch,
BillingInterval,
FreeTrial,
FullCusProduct,
PreviewLineItem,
Price,
UsageModel,
AttachConfig,
} from "@autumn/shared";
import {
addBillingIntervalUnix,
@@ -39,6 +38,8 @@ const getNextCycleAt = ({
interval,
now,
freeTrial,
branch,
curCusProduct,
}: {
prices: Price[];
stripeSubs: Stripe.Subscription[];
@@ -46,9 +47,17 @@ const getNextCycleAt = ({
interval: BillingInterval;
now?: number;
freeTrial?: FreeTrial | null;
branch: AttachBranch;
curCusProduct?: FullCusProduct;
}) => {
now = now || Date.now();
if (branch == AttachBranch.NewVersion && curCusProduct?.free_trial) {
return {
next_cycle_at: curCusProduct.trial_ends_at,
};
}
if (freeTrial) {
return {
next_cycle_at:
@@ -85,12 +94,14 @@ export const getUpgradeProductPreview = async ({
branch,
now,
withPrepaid = false,
config,
}: {
req: ExtendedRequest;
attachParams: AttachParams;
branch: AttachBranch;
now: number;
withPrepaid?: boolean;
config?: AttachConfig;
}) => {
const { logtail: logger } = req;
@@ -123,18 +134,22 @@ export const getUpgradeProductPreview = async ({
? stripeSubs[0].current_period_end * 1000
: undefined;
let freeTrial = attachParams.freeTrial;
if (config?.carryTrial && curCusProduct?.free_trial) {
freeTrial = curCusProduct.free_trial;
}
const newPreviewItems = await getItemsForNewProduct({
newProduct,
attachParams,
now,
anchorToUnix,
freeTrial: attachParams.freeTrial,
freeTrial,
stripeSubs,
logger,
withPrepaid,
});
// const lastInterval = getLastInterval({ prices: newProduct.prices });
const lastInterval = getFirstInterval({ prices: newProduct.prices });
let dueNextCycle = undefined;
@@ -146,6 +161,8 @@ export const getUpgradeProductPreview = async ({
interval: lastInterval,
now,
freeTrial: attachParams.freeTrial,
branch,
curCusProduct,
});
let nextCycleItems = await getItemsForNewProduct({

View File

@@ -78,18 +78,6 @@ export const getCustomerDetails = async ({
(cp: FullCusProduct) => cp.subscription_ids || []
);
// if (org.config.api_version >= BREAK_API_VERSION && org.stripe_connected) {
// let stripeCli = createStripeCli({
// org,
// env,
// });
// subs = await getStripeSubs({
// stripeCli,
// subIds,
// expand: withRewards ? ["discounts"] : undefined,
// });
// }
const subs = customer.subscriptions || [];
const { main, addOns } = await processFullCusProducts({
fullCusProducts: cusProducts,

View File

@@ -1,8 +1,7 @@
import { CusService } from "@/internal/customers/CusService.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
import RecaseError from "@/utils/errorUtils.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { CusProductStatus, ErrCode, FullCusProduct } from "@autumn/shared";
import { ErrCode, FullCusProduct } from "@autumn/shared";
import { Router } from "express";
import { expireCusProduct } from "../handlers/handleCusProductExpired.js";
import { RELEVANT_STATUSES } from "../cusProducts/CusProductService.js";
@@ -55,6 +54,8 @@ expireRouter.post("", async (req, res) =>
});
}
// Handle case if there are two products to expire...
for (const cusProduct of cusProductsToExpire) {
await expireCusProduct({
req,

View File

@@ -60,6 +60,7 @@ export class ApiKeyService {
org,
features: (data.org.features || []) as Feature[],
env,
userId: data.user_id,
};
// console.log("result", result);

View File

@@ -35,6 +35,7 @@ export const createKey = async ({
db,
env,
name,
userId,
orgId,
prefix,
meta,
@@ -45,6 +46,7 @@ export const createKey = async ({
orgId: string;
prefix: string;
meta: any;
userId?: string;
}) => {
const apiKey = generateApiKey(42, prefix);
const hashedKey = hashApiKey(apiKey);
@@ -52,7 +54,7 @@ export const createKey = async ({
const apiKeyData: ApiKey = {
id: generateId("key"),
org_id: orgId,
user_id: "",
user_id: userId || null,
name,
prefix: apiKey.substring(0, 14),
created_at: Date.now(),

View File

@@ -17,6 +17,7 @@ import {
} from "@/external/stripe/stripeOnboardingUtils.js";
import { clearOrgCache } from "../orgs/orgUtils/clearOrgCache.js";
import * as crypto from "crypto";
import { isStripeConnected } from "../orgs/orgUtils.js";
export const devRouter: Router = Router();
@@ -64,6 +65,7 @@ devRouter.post("/api_key", withOrgAuth, async (req: any, res) =>
env,
name,
orgId,
userId: req.user?.id,
prefix,
meta: {},
});
@@ -222,6 +224,7 @@ export const handleGetOtp = async (req: any, res: any) =>
fromCli: true,
generatedAt: new Date().toISOString(),
},
userId: req.user?.id,
});
const prodKey = await createKey({
@@ -234,6 +237,7 @@ export const handleGetOtp = async (req: any, res: any) =>
fromCli: true,
generatedAt: new Date().toISOString(),
},
userId: req.user?.id,
});
// console.log("New keys created:");
@@ -245,7 +249,7 @@ export const handleGetOtp = async (req: any, res: any) =>
orgId: cacheData.orgId,
});
let stripeConnected = org.stripe_connected;
let stripeConnected = isStripeConnected({ org });
let responseData = {
...cacheData,

View File

@@ -16,8 +16,57 @@ import { OrgService } from "../OrgService.js";
import { AppEnv } from "@autumn/shared";
import { nullish } from "@/utils/genUtils.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
import { disconnectStripe } from "./handleDeleteStripe.js";
export const connectStripe = async ({
db,
orgId,
logger,
apiKey,
env,
}: {
db: any;
orgId: string;
logger: any;
apiKey: string;
env: AppEnv;
}) => {
// 1. Check if key is valid
await checkKeyValid(apiKey);
let stripe = new Stripe(apiKey);
let account = await stripe.accounts.retrieve();
// 2. Disconnect existing webhook endpoints
const curWebhooks = await stripe.webhookEndpoints.list();
for (const webhook of curWebhooks.data) {
if (webhook.url.includes(orgId)) {
await stripe.webhookEndpoints.del(webhook.id);
}
}
// 3. Create webhook endpoint
let webhook = await createWebhookEndpoint(apiKey, env, orgId);
// 3. Return encrypted
if (env === AppEnv.Sandbox) {
return {
test_api_key: encryptData(apiKey),
test_webhook_secret: encryptData(webhook.secret as string),
env,
defaultCurrency: account.default_currency,
};
} else {
return {
live_api_key: encryptData(apiKey),
live_webhook_secret: encryptData(webhook.secret as string),
env,
stripeCurrency: account.default_currency,
};
}
};
export const connectAllStripe = async ({
db,
orgId,
logger,
@@ -114,7 +163,7 @@ export const handleConnectStripe = async (req: any, res: any) =>
}
let { defaultCurrency: finalDefaultCurrency, stripeConfig } =
await connectStripe({
await connectAllStripe({
db,
orgId,
logger,

View File

@@ -3,24 +3,28 @@ import { OrgService } from "../OrgService.js";
import { createStripeCli } from "@/external/stripe/utils.js";
import { clearOrgCache } from "../orgUtils/clearOrgCache.js";
import { AppEnv, Organization } from "@autumn/shared";
import { isStripeConnected } from "../orgUtils.js";
export const disconnectStripe = async (org: Organization) => {
if (isStripeConnected({ org, env: AppEnv.Sandbox })) {
const testStripeCli = createStripeCli({ org, env: AppEnv.Sandbox });
const liveStripeCli = createStripeCli({ org, env: AppEnv.Live });
const testWebhooks = await testStripeCli.webhookEndpoints.list();
for (const webhook of testWebhooks.data) {
if (webhook.url.includes(org.id)) {
await testStripeCli.webhookEndpoints.del(webhook.id);
}
}
}
if (isStripeConnected({ org, env: AppEnv.Live })) {
const liveStripeCli = createStripeCli({ org, env: AppEnv.Live });
const liveWebhooks = await liveStripeCli.webhookEndpoints.list();
for (const webhook of liveWebhooks.data) {
if (webhook.url.includes(org.id)) {
await liveStripeCli.webhookEndpoints.del(webhook.id);
}
}
}
};
export const handleDeleteStripe = async (req: any, res: any) =>

View File

@@ -4,7 +4,26 @@ import { AppEnv, ErrCode, FrontendOrg, Organization } from "@autumn/shared";
import { createStripeCli } from "@/external/stripe/utils.js";
import { OrgService } from "./OrgService.js";
import { FeatureService } from "../features/FeatureService.js";
import { createSvixApp } from "@/external/svix/svixHelpers.js";
import { notNullish } from "@/utils/genUtils.js";
export const isStripeConnected = ({
org,
env,
}: {
org: Organization;
env?: AppEnv;
}) => {
if (env === AppEnv.Sandbox) {
return notNullish(org.stripe_config?.test_api_key);
} else if (env === AppEnv.Live) {
return notNullish(org.stripe_config?.live_api_key);
} else {
return (
notNullish(org.stripe_config?.test_api_key) &&
notNullish(org.stripe_config?.live_api_key)
);
}
};
export const constructOrg = ({ id, slug }: { id: string; slug: string }) => {
return {
@@ -49,19 +68,19 @@ export const deleteStripeWebhook = async ({
};
export const getStripeWebhookSecret = (org: Organization, env: AppEnv) => {
if (!org.stripe_config) {
const webhookSecret =
env === AppEnv.Sandbox
? org.stripe_config?.test_webhook_secret
: org.stripe_config?.live_webhook_secret;
if (!webhookSecret) {
throw new RecaseError({
code: ErrCode.StripeConfigNotFound,
message: `Stripe config not found for org ${org.id}`,
message: `Stripe webhook secret not found for org ${org.id}`,
statusCode: 400,
});
}
const webhookSecret =
env === AppEnv.Sandbox
? org.stripe_config.test_webhook_secret
: org.stripe_config!.live_webhook_secret;
return decryptData(webhookSecret);
};

View File

@@ -1,26 +1,68 @@
import { generateId } from "better-auth";
import { NextFunction, Router } from "express";
import { member, organizations, user as userTable } from "@autumn/shared";
import {
AppEnv,
member,
Organization,
organizations,
StripeConfig,
user as userTable,
} from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { slugify } from "@/utils/genUtils.js";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { connectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { z } from "zod";
import { createKey } from "../dev/api-keys/apiKeyUtils.js";
import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js";
import { Autumn } from "autumn-js";
const platformRouter = Router();
const platformAuthMiddleware = (req: any, res: any, next: NextFunction) => {
const platformAuthMiddleware = async (
req: any,
res: any,
next: NextFunction
) => {
if (!process.env.AUTUMN_SECRET_KEY) next();
try {
let autumn = new Autumn();
const { data, error } = await autumn.check({
customer_id: req.org.id,
feature_id: "platform",
});
if (error) {
throw error;
}
if (!data?.allowed) {
res.status(403).json({
message:
"You're not allowed to access the platform API. Please contact hey@useautumn.com to request access!",
code: "not_allowed",
});
return;
}
next();
} catch (error) {
res.status(500).json({
message: "Failed to check if org is allowed to access platform",
code: "internal_error",
});
}
};
platformRouter.use(platformAuthMiddleware);
const ExchangeSchema = z.object({
organization: z.string(),
email: z.string(),
stripe_test_key: z.string().nonempty(),
stripe_live_key: z.string().nonempty(),
email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
stripe_test_key: z.string().nonempty().optional(),
stripe_live_key: z.string().nonempty().optional(),
});
platformRouter.post("/exchange", (req: any, res: any) =>
@@ -31,57 +73,172 @@ platformRouter.post("/exchange", (req: any, res: any) =>
handler: async (req: ExtendedRequest, res: any) => {
let { organization, email, stripe_test_key, stripe_live_key } = req.body;
// 1. Create user with email
const { db } = req;
const { db, logger } = req;
// let user = await db.insert(userTable).values({
// id: generateId(),
// name: "",
// email,
// emailVerified: true,
// createdAt: new Date(),
// updatedAt: new Date(),
// role: "user",
// banned: false,
// banReason: null,
// banExpires: null,
// });
ExchangeSchema.parse({
organization,
email,
stripe_test_key,
stripe_live_key,
});
// let { defaultCurrency, stripeConfig } = await connectStripe({
// db,
// orgId: generateId(),
// logger: req.logtail,
// testApiKey: stripe_test_key,
// liveApiKey: stripe_live_key,
// successUrl: "https://useautumn.com",
// });
// 1. Check if user with this email already exists
let user = await db.query.user.findFirst({
where: eq(userTable.email, email),
});
// // 2. Create org
// let orgId = generateId();
// await db.insert(organizations).values({
// id: orgId,
// slug: `${slugify(organization)}_${Math.floor(10000000 + Math.random() * 90000000)}`,
// name: organization,
// logo: "",
// createdAt: new Date(),
// metadata: "",
// stripe_connected: true,
// default_currency: defaultCurrency,
// stripe_config: stripeConfig,
// });
if (!user) {
[user] = await db
.insert(userTable)
.values({
id: generateId(),
name: "",
email,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
role: "user",
banned: false,
banReason: null,
banExpires: null,
createdBy: req.org.id,
})
.returning();
}
// // 3. Create membership
// await db.insert(member).values({
// id: generateId(),
// organizationId: orgId,
// userId: user!.id,
// role: "owner",
// createdAt: new Date(),
// });
logger.info(`User found / created: ${user.id} (${email})`);
let org: Organization;
let membership = await db.query.member.findFirst({
where: and(eq(member.userId, user.id!), eq(member.role, "owner")),
});
if (!membership) {
logger.info(`Connected to Stripe`);
// 2. Create org
let orgId = generateId();
[org] = (await db
.insert(organizations)
.values({
id: orgId,
slug: `platform_org_${Math.floor(10000000 + Math.random() * 90000000)}`,
name: `Platform Org`,
logo: "",
createdAt: new Date(),
metadata: "",
})
.returning()) as [Organization];
await db.insert(member).values({
id: generateId(),
organizationId: orgId,
userId: user.id!,
role: "owner",
createdAt: new Date(),
});
await afterOrgCreated({ org });
} else {
org = (await db.query.organizations.findFirst({
where: eq(organizations.id, membership.organizationId),
})) as Organization;
}
let sandboxKey, prodKey;
let finalStripeConfig: any = {};
let defaultCurrency = org.default_currency || "usd";
if (stripe_test_key) {
let { test_api_key, test_webhook_secret, stripeCurrency } =
await connectStripe({
db,
orgId: org.id,
logger: req.logtail,
apiKey: stripe_test_key,
env: AppEnv.Sandbox,
});
sandboxKey = await createKey({
db,
orgId: org.id,
env: AppEnv.Sandbox,
name: "Platform API Key",
prefix: "am_sk_test",
meta: {},
});
finalStripeConfig = {
...finalStripeConfig,
test_api_key,
test_webhook_secret,
};
if (!defaultCurrency) {
defaultCurrency = stripeCurrency || "usd";
}
}
if (stripe_live_key) {
let { live_api_key, live_webhook_secret, stripeCurrency } =
await connectStripe({
db,
orgId: org.id,
logger: req.logtail,
apiKey: stripe_live_key,
env: AppEnv.Live,
});
prodKey = await createKey({
db,
orgId: org.id,
env: AppEnv.Live,
name: "Platform API Key",
prefix: "am_sk_live",
meta: {},
});
finalStripeConfig = {
...finalStripeConfig,
live_api_key,
live_webhook_secret,
};
if (!defaultCurrency) {
defaultCurrency = stripeCurrency || "usd";
}
}
if (!org.stripe_config?.success_url) {
finalStripeConfig.success_url = `https://useautumn.com`;
}
await db
.update(organizations)
.set({
default_currency: defaultCurrency,
stripe_connected: true,
stripe_config: {
...org.stripe_config,
...finalStripeConfig,
} as StripeConfig,
})
.where(eq(organizations.id, org.id));
res.status(200).json({
// message: "User created",
// user,
// org: {
// id: org.id,
// slug: org.slug,
// name: org.name,
// },
// user: {
// id: user.id!,
// email,
// },
api_keys: {
sandbox: sandboxKey,
production: prodKey,
},
});
},
})

View File

@@ -21,6 +21,7 @@ import {
isPriceItem,
} from "../../product-items/productItemUtils/getItemType.js";
import { isFreeProduct } from "../../productUtils.js";
import { isStripeConnected } from "@/internal/orgs/orgUtils.js";
const productDetailsSame = (prod1: Product, prod2: UpdateProduct) => {
if (notNullish(prod2.id) && prod1.id != prod2.id) {
@@ -59,7 +60,7 @@ const updateStripeProductNames = async ({
newName: string;
logger: any;
}) => {
if (!org.stripe_connected) return;
if (!isStripeConnected({ org, env: curProduct.env as AppEnv })) return;
const stripeCli = createStripeCli({
org,
env: curProduct.env as AppEnv,
@@ -81,7 +82,7 @@ const updateStripeProductNames = async ({
error,
stripeProdId,
newName,
},
}
);
}
@@ -103,7 +104,7 @@ const updateStripeProductNames = async ({
});
} catch (error: any) {
logger.error(
`Error updating price ${price.id} name in Stripe: ${error.message}`,
`Error updating price ${price.id} name in Stripe: ${error.message}`
);
}
}
@@ -198,7 +199,7 @@ export const handleUpdateProductDetails = async ({
// Update product name in Stripe
if (curProduct.name !== newProduct.name && notNullish(newProduct.name)) {
logger.info(
`Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`,
`Updating product (${curProduct.id}) name in Stripe to ${newProduct.name}`
);
await updateStripeProductNames({
db,

View File

@@ -12,7 +12,7 @@ const handleResFinish = (req: any, res: any) => {
{
statusCode: res.statusCode,
res: res.locals.responseBody,
},
}
);
}
} catch (error) {
@@ -29,7 +29,7 @@ const parseCustomerIdFromUrl = (url: string): string | undefined => {
const cleanUrl = url.split("?")[0].replace(/^\/+|\/+$/g, "");
const segments = cleanUrl.split("/");
const customersIndex = segments.findIndex(
(segment) => segment === "customers",
(segment) => segment === "customers"
);
if (customersIndex !== -1 && segments[customersIndex + 1]) {
@@ -48,6 +48,7 @@ export const analyticsMiddleware = async (req: any, res: any, next: any) => {
body: req.body,
customer_id:
req?.body?.customer_id || parseCustomerIdFromUrl(req.originalUrl),
user_id: req.userId || null,
};
if (req.span) {

View File

@@ -80,7 +80,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
});
}
let { org, features, env } = data;
let { org, features, env, userId } = data;
req.orgId = org.id;
req.env = env;
req.minOrg = {
@@ -90,6 +90,7 @@ export const verifySecretKey = async (req: any, res: any, next: any) => {
req.org = org;
req.features = features;
req.authType = AuthType.SecretKey;
req.userId = userId;
next();
};

View File

@@ -8,6 +8,7 @@ import {
} from "../../external/stripe/stripeCusUtils.js";
import { CusService } from "@/internal/customers/CusService.js";
import Stripe from "stripe";
import { deleteCusCache } from "@/internal/customers/cusCache/updateCachedCus.js";
export const createCusInStripe = async ({
customer,
@@ -83,6 +84,12 @@ export const initCustomer = async ({
if (customer) {
await autumn.customers.delete(customerId);
await deleteCusCache({
db,
customerId: customerId,
orgId: org.id,
env: env,
});
}
try {
@@ -95,6 +102,9 @@ export const initCustomer = async ({
env: env,
})) as Customer;
// console.log("customer id", customerId);
// console.log("org id", org.id);
// console.log("env", env);
// console.log("customer", customer);
const stripeCli = createStripeCli({ org: org, env: env });

View File

@@ -130,8 +130,9 @@ describe(`${chalk.yellowBright(`attach/${testCase}: Testing attach pro annual to
testClockId,
advanceTo: addHours(
addMonths(curUnix, 1),
hoursToFinalizeInvoice,
hoursToFinalizeInvoice
).getTime(),
waitForSeconds: 30,
});
await expectInvoiceAfterUsage({

View File

@@ -0,0 +1,168 @@
import { expect } from "chai";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { initCustomer } from "@/utils/scriptUtils/initCustomer.js";
import {
APIVersion,
AppEnv,
BillingInterval,
Organization,
ProductV2,
} from "@autumn/shared";
import chalk from "chalk";
import Stripe from "stripe";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { setupBefore } from "tests/before.js";
import { createProducts } from "tests/utils/productUtils.js";
import { addPrefixToProducts, runAttachTest } from "../utils.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
import { TestFeature } from "tests/setup/v2Features.js";
import { replaceItems } from "../utils.js";
import { constructPriceItem } from "@/internal/products/product-items/productItemUtils.js";
import runUpdateEntsTest from "../updateEnts/expectUpdateEnts.js";
import { timeout } from "@/utils/genUtils.js";
import { advanceTestClock } from "tests/utils/stripeUtils.js";
import { addHours, addMonths, addWeeks } from "date-fns";
import { hoursToFinalizeInvoice } from "tests/utils/constants.js";
import { getExpectedInvoiceTotal } from "tests/utils/expectUtils/expectInvoiceUtils.js";
export let pro = constructProduct({
items: [constructArrearItem({ featureId: TestFeature.Words })],
type: "pro",
trial: true,
});
const testCase = "newVersion2";
describe(`${chalk.yellowBright(`${testCase}: Testing attach new version for trial product`)}`, () => {
let customerId = testCase;
let autumn: AutumnInt = new AutumnInt({ version: APIVersion.v1_4 });
let testClockId: string;
let db: DrizzleCli, org: Organization, env: AppEnv;
let stripeCli: Stripe;
let curUnix = new Date().getTime();
before(async function () {
await setupBefore(this);
const { autumnJs } = this;
db = this.db;
org = this.org;
env = this.env;
stripeCli = this.stripeCli;
addPrefixToProducts({
products: [pro],
prefix: testCase,
});
await createProducts({
db,
orgId: org.id,
env,
autumn,
products: [pro],
customerId,
});
const { testClockId: testClockId1 } = await initCustomer({
autumn: autumnJs,
customerId,
db,
org,
env,
attachPm: "success",
});
testClockId = testClockId1!;
});
it("should attach pro product", async function () {
await runAttachTest({
autumn,
customerId,
product: pro,
stripeCli,
db,
org,
env,
});
});
let usage = 50000;
let newPro: ProductV2;
it("should update product to new version", async function () {
newPro = structuredClone(pro);
let newItems = replaceItems({
items: pro.items,
interval: BillingInterval.Month,
newItem: constructPriceItem({
price: 100,
interval: BillingInterval.Month,
}),
});
newPro.version = 2;
newPro.items = newItems;
await autumn.products.update(pro.id, {
items: newItems,
});
});
return;
it("should attach pro v2", async function () {
await runUpdateEntsTest({
autumn,
stripeCli,
customerId,
customProduct: newPro,
newVersion: 2,
db,
org,
env,
});
});
// it("should have correct invoice total on next cycle", async function () {
// const invoiceTotal = await getExpectedInvoiceTotal({
// org,
// env,
// customerId,
// productId: pro.id,
// stripeCli,
// db,
// usage: [
// {
// featureId: TestFeature.Words,
// value: usage,
// },
// ],
// onlyIncludeMonthly: true,
// });
// let curUnix = Date.now();
// curUnix = await advanceTestClock({
// stripeCli,
// testClockId,
// advanceTo: addMonths(curUnix, 1).getTime(),
// waitForSeconds: 30,
// });
// await advanceTestClock({
// stripeCli,
// testClockId,
// advanceTo: addHours(curUnix, hoursToFinalizeInvoice).getTime(),
// waitForSeconds: 10,
// });
// const customer = await autumn.customers.get(customerId);
// const invoice = customer.invoices[0];
// expect(invoice.total).to.equal(
// invoiceTotal,
// "invoice total after 1 cycle should be correct"
// );
// });
});

View File

@@ -4,10 +4,13 @@ import {
timestamp,
boolean,
integer,
foreignKey,
} from "drizzle-orm/pg-core";
import { organizations } from "./schema.js";
export const user = pgTable("user", {
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
@@ -25,7 +28,16 @@ export const user = pgTable("user", {
banned: boolean("banned"),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
});
createdBy: text("created_by"),
},
(table) => [
foreignKey({
columns: [table.createdBy],
foreignColumns: [organizations.id],
name: "user_created_by_fkey",
}),
]
);
export const session = pgTable("session", {
id: text("id").primaryKey(),
@@ -66,10 +78,10 @@ export const verification = pgTable("verification", {
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
() => /* @__PURE__ */ new Date()
),
updatedAt: timestamp("updated_at", { withTimezone: true }).$defaultFn(
() => /* @__PURE__ */ new Date(),
() => /* @__PURE__ */ new Date()
),
}).enableRLS();

View File

@@ -3,7 +3,7 @@ import { AppEnv } from "../genModels/genEnums.js";
export type ApiKey = {
id: string;
org_id: string;
user_id: string;
user_id: string | null;
name: string;
prefix: string;
created_at: number;

View File

@@ -17,11 +17,11 @@ export type SvixConfig = {
};
export type StripeConfig = {
test_api_key: string;
live_api_key: string;
test_webhook_secret: string;
live_webhook_secret: string;
success_url: string;
test_api_key?: string;
live_api_key?: string;
test_webhook_secret?: string;
live_webhook_secret?: string;
success_url?: string;
};
// logo: text("logo"),
@@ -56,7 +56,7 @@ export const organizations = pgTable(
(table) => [
unique("organizations_test_pkey_key").on(table.test_pkey),
unique("organizations_live_pkey_key").on(table.live_pkey),
],
]
);
export type Organization = typeof organizations.$inferSelect & {

View File

@@ -5,6 +5,7 @@ import { CusProductStripeLink } from "./CusProductStripeLink";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
import { notNullish } from "@/utils/genUtils";
import { differenceInDays, subDays } from "date-fns";
export const CusProductStatusItem = ({
cusProduct,
@@ -12,12 +13,14 @@ export const CusProductStatusItem = ({
cusProduct: FullCusProduct;
}) => {
const getStatus = () => {
if (cusProduct.status == CusProductStatus.Expired) {
return CusProductStatus.Expired;
}
const trialing =
cusProduct.trial_ends_at && cusProduct.trial_ends_at > Date.now();
const canceled =
notNullish(cusProduct.canceled_at) &&
cusProduct.status !== CusProductStatus.Expired;
const canceled = notNullish(cusProduct.canceled_at);
if (canceled) return "canceled";
if (trialing) {
@@ -27,14 +30,24 @@ export const CusProductStatusItem = ({
return cusProduct.status;
};
const isCanceled = notNullish(cusProduct.canceled_at);
const getTitle = () => {
const status = getStatus();
if (status == CusProductStatus.Trialing) {
const daysTillEnd = differenceInDays(
new Date(cusProduct.trial_ends_at!),
new Date()
);
return `trial (${daysTillEnd}d)`;
}
return keyToTitle(getStatus()).toLowerCase();
};
const statusToColor: Record<CusProductStatus | "canceled", string> = {
[CusProductStatus.Active]: "bg-lime-500",
[CusProductStatus.Expired]: "bg-stone-800",
[CusProductStatus.PastDue]: "bg-red-500",
[CusProductStatus.Scheduled]: "bg-blue-500",
[CusProductStatus.Trialing]: "bg-yellow-400",
[CusProductStatus.Trialing]: "bg-blue-400",
canceled: "bg-gray-500",
[CusProductStatus.Unknown]: "bg-gray-500",
};
@@ -45,7 +58,7 @@ export const CusProductStatusItem = ({
variant="status"
className={cn("h-fit", statusToColor[getStatus()])}
>
{keyToTitle(getStatus()).toLowerCase()}
{getTitle()}
</Badge>
{/* {isCanceled && (
<Badge variant="status" className="ml-2 bg-gray-500">

View File

@@ -56,7 +56,7 @@ export const AttachModal = ({
if (entityId) {
const entity = entities.find(
(e: Entity) => e.id === entityId || e.internal_id === entityId,
(e: Entity) => e.id === entityId || e.internal_id === entityId
);
const entityName = entity?.name || entity?.id || entity?.internal_id;
return `${cusName} (${entityName})`;
@@ -110,6 +110,7 @@ export const AttachModal = ({
}
const dueToday = preview?.due_today;
if (dueToday && dueToday.total == 0) {
return "Confirm";
}
@@ -169,7 +170,7 @@ export const AttachModal = ({
navigateTo(
`/integrations/stripe?redirect=${redirectUrl}`,
navigation,
env,
env
);
} else {
toast.error(getBackendErr(error, "Error creating product"));
@@ -228,7 +229,7 @@ export const AttachModal = ({
<DialogFooter
className={cn(
"bg-stone-100 flex items-center h-10 gap-0 border-t border-zinc-200",
mainWidth,
mainWidth
)}
>
{invoiceAllowed() && (

View File

@@ -8,6 +8,7 @@ import {
ProductItem,
ProductItemFeatureType,
} from "@autumn/shared";
import { format } from "date-fns";
import { InfoIcon } from "lucide-react";
export const AttachInfo = () => {
@@ -44,13 +45,27 @@ export const AttachInfo = () => {
);
});
let text = `The customer is currently on ${currentProduct.name} v${currentProduct.version}. Switching to v${product.version} will update the customer's features immediately, and from ${formatUnixToDate(preview.due_next_cycle.due_at)} onwards they will pay any new prices`;
return (
<>
<span>
You are switching this customer to version {product.version} of{" "}
{product.name}. Their features will update immediately and from{" "}
{format(preview.due_next_cycle.due_at, "d MMM")} onwards, they will
pay any new prices
{usagePriceExists ? " (including usage from the last cycle)" : ""}.
</span>
</>
);
if (usagePriceExists) {
text += ` (including usage from the last cycle).`;
} else {
text += `.`;
}
const text = `You are switching this customer to version ${product.version} of ${product.name}.`;
// let text = `The customer is currently on ${currentProduct.name} v${currentProduct.version}. Switching to v${product.version} will update the customer's features immediately, and from ${formatUnixToDate(preview.due_next_cycle.due_at)} onwards they will pay any new prices`;
// if (usagePriceExists) {
// text += ` (including usage from the last cycle).`;
// } else {
// text += `.`;
// }
return text;
}

View File

@@ -40,7 +40,6 @@ const CreateAPIKey = () => {
}, [copied]);
const handleCreate = async () => {
console.log("creating api key", apiKeyName ? apiKeyName : name);
setLoading(true);
try {
const { api_key } = await DevService.createAPIKey(axiosInstance, {